diff --git a/.swiftformat b/.swiftformat index 16eb177a..94d0f5d8 100644 --- a/.swiftformat +++ b/.swiftformat @@ -36,7 +36,8 @@ --disable redundantSelf # Modern Swift patterns ---self init-only +# For Swift 6 compatibility, preserve self references +--self insert --selfrequired --importgrouping testable-last --patternlet inline diff --git a/.swiftlint.yml b/.swiftlint.yml index 1579e246..1068985d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -84,6 +84,8 @@ disabled_rules: - todo # Disable opening_brace as it conflicts with SwiftFormat's multiline wrapping - opening_brace + # Note: Swift 6 requires more explicit self references + # SwiftFormat is configured to preserve these with --disable redundantSelf # Rule parameters type_name: diff --git a/VibeTunnel.xcodeproj/project.pbxproj b/VibeTunnel.xcodeproj/project.pbxproj index 1352bef8..c2047594 100644 --- a/VibeTunnel.xcodeproj/project.pbxproj +++ b/VibeTunnel.xcodeproj/project.pbxproj @@ -481,7 +481,7 @@ CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; @@ -498,7 +498,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.3; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -514,7 +514,7 @@ CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; @@ -531,7 +531,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.3; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -543,12 +543,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -561,12 +561,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -578,11 +578,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.steipete.VibeTunnelUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index 344fc984..48001bab 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/Core/Models/DashboardAccessMode.swift b/VibeTunnel/Core/Models/DashboardAccessMode.swift index 8b742420..bd03f64f 100644 --- a/VibeTunnel/Core/Models/DashboardAccessMode.swift +++ b/VibeTunnel/Core/Models/DashboardAccessMode.swift @@ -2,27 +2,27 @@ import Foundation /// Dashboard access mode enum DashboardAccessMode: String, CaseIterable { - case localhost = "localhost" - case network = "network" - + case localhost + case network + var displayName: String { switch self { case .localhost: "Localhost only" case .network: "Network" } } - + var bindAddress: String { switch self { case .localhost: "127.0.0.1" case .network: "0.0.0.0" } } - + var description: String { switch self { case .localhost: "Only accessible from this Mac" case .network: "Accessible from other devices on the network" } } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/BasicAuthMiddleware.swift b/VibeTunnel/Core/Services/BasicAuthMiddleware.swift index 0439917d..b04aab84 100644 --- a/VibeTunnel/Core/Services/BasicAuthMiddleware.swift +++ b/VibeTunnel/Core/Services/BasicAuthMiddleware.swift @@ -1,68 +1,76 @@ import Foundation +import HTTPTypes import Hummingbird import HummingbirdCore -import HTTPTypes import NIOCore /// Middleware that implements HTTP Basic Authentication struct BasicAuthMiddleware: RouterMiddleware { let password: String let realm: String - + init(password: String, realm: String = "VibeTunnel Dashboard") { self.password = password self.realm = realm } - - func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + + func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) + async throws -> Response + { // Skip auth for health check endpoint if request.uri.path == "/api/health" { return try await next(request, context) } - + // Extract authorization header guard let authHeader = request.headers[.authorization], - authHeader.hasPrefix("Basic ") else { + authHeader.hasPrefix("Basic ") + else { return unauthorizedResponse() } - + // Decode base64 credentials let base64Credentials = String(authHeader.dropFirst(6)) guard let credentialsData = Data(base64Encoded: base64Credentials), - let credentials = String(data: credentialsData, encoding: .utf8) else { + let credentials = String(data: credentialsData, encoding: .utf8) + else { return unauthorizedResponse() } - + // Split username:password let parts = credentials.split(separator: ":", maxSplits: 1) guard parts.count == 2 else { return unauthorizedResponse() } - + // We ignore the username and only check password let providedPassword = String(parts[1]) - + // Verify password guard providedPassword == password else { return unauthorizedResponse() } - + // Password correct, continue with request return try await next(request, context) } - + private func unauthorizedResponse() -> Response { var headers = HTTPFields() headers[.wwwAuthenticate] = "Basic realm=\"\(realm)\"" - + let message = "Authentication required" var buffer = ByteBuffer() buffer.writeString(message) - + return Response( status: .unauthorized, headers: headers, body: ResponseBody(byteBuffer: buffer) ) } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/CastFileGenerator.swift b/VibeTunnel/Core/Services/CastFileGenerator.swift index 49a393be..11a1f4cf 100644 --- a/VibeTunnel/Core/Services/CastFileGenerator.swift +++ b/VibeTunnel/Core/Services/CastFileGenerator.swift @@ -5,7 +5,7 @@ import Logging /// Format specification: https://docs.asciinema.org/manual/asciicast/v2/ struct CastFileGenerator { private let logger = Logger(label: "VibeTunnel.CastFileGenerator") - + struct CastHeader: Codable { let version: Int = 2 let width: Int @@ -16,7 +16,7 @@ struct CastFileGenerator { let command: String? let title: String? let env: [String: String]? - + enum CodingKeys: String, CodingKey { case version case width @@ -29,13 +29,13 @@ struct CastFileGenerator { case env } } - + struct CastEvent { let time: TimeInterval let eventType: String let data: String } - + /// Generate a cast file from a session's stream-out file func generateCastFile( sessionId: String, @@ -44,49 +44,53 @@ struct CastFileGenerator { height: Int = 24, title: String? = nil, command: String? = nil - ) throws -> Data { + ) + throws -> Data + { guard FileManager.default.fileExists(atPath: streamOutPath) else { throw CastFileError.fileNotFound(streamOutPath) } - + let content = try String(contentsOfFile: streamOutPath, encoding: .utf8) let lines = content.components(separatedBy: .newlines) .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - + var outputData = Data() var events: [CastEvent] = [] var startTime: Date? var sessionWidth = width var sessionHeight = height - + // Parse the stream-out file for line in lines { guard let data = line.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) else { + let parsed = try? JSONSerialization.jsonObject(with: data) + else { continue } - + // Check if it's a header if let dict = parsed as? [String: Any], - dict["version"] as? Int != nil, - let w = dict["width"] as? Int, - let h = dict["height"] as? Int { - sessionWidth = w - sessionHeight = h + dict["version"] is Int, + let width = dict["width"] as? Int, + let height = dict["height"] as? Int + { + sessionWidth = width + sessionHeight = height continue } - + // Parse as event [timestamp, type, data] if let array = parsed as? [Any], array.count >= 3, let timestamp = array[0] as? TimeInterval, let eventType = array[1] as? String, - let eventData = array[2] as? String { - + let eventData = array[2] as? String + { if startTime == nil { startTime = Date() } - + events.append(CastEvent( time: timestamp, eventType: eventType, @@ -94,7 +98,7 @@ struct CastFileGenerator { )) } } - + // Generate header let header = CastHeader( width: sessionWidth, @@ -106,26 +110,26 @@ struct CastFileGenerator { title: title, env: nil ) - + // Write header as first line let headerData = try JSONEncoder().encode(header) outputData.append(headerData) outputData.append(Data("\n".utf8)) - + // Write events let encoder = JSONEncoder() encoder.outputFormatting = .withoutEscapingSlashes - + for event in events { let eventArray: [Any] = [event.time, event.eventType, event.data] let eventData = try JSONSerialization.data(withJSONObject: eventArray) outputData.append(eventData) outputData.append(Data("\n".utf8)) } - + return outputData } - + /// Generate a cast file and save it to disk func saveCastFile( sessionId: String, @@ -135,7 +139,9 @@ struct CastFileGenerator { height: Int = 24, title: String? = nil, command: String? = nil - ) throws { + ) + throws + { let castData = try generateCastFile( sessionId: sessionId, streamOutPath: streamOutPath, @@ -144,16 +150,18 @@ struct CastFileGenerator { title: title, command: command ) - + try castData.write(to: URL(fileURLWithPath: outputPath)) logger.info("Cast file saved to: \(outputPath)") } - + /// Generate a live cast stream that can be consumed in real-time func streamCastEvents( from streamOutPath: String, startTime: Date - ) -> AsyncStream { + ) + -> AsyncStream + { AsyncStream { continuation in Task { let fileDescriptor = open(streamOutPath, O_RDONLY) @@ -162,24 +170,24 @@ struct CastFileGenerator { continuation.finish() return } - + defer { close(fileDescriptor) continuation.finish() } - + var lastReadPosition: off_t = 0 - + while !Task.isCancelled { let currentPosition = lseek(fileDescriptor, 0, SEEK_END) let bytesToRead = currentPosition - lastReadPosition - + if bytesToRead > 0 { lseek(fileDescriptor, lastReadPosition, SEEK_SET) - + let buffer = UnsafeMutablePointer.allocate(capacity: Int(bytesToRead) + 1) defer { buffer.deallocate() } - + let bytesRead = read(fileDescriptor, buffer, Int(bytesToRead)) if bytesRead > 0 { let data = Data(bytes: buffer, count: bytesRead) @@ -197,26 +205,27 @@ struct CastFileGenerator { lastReadPosition = currentPosition } } - + // Sleep briefly before checking again try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds } } } } - + private func processLineToAsciinemaEvent(line: String, startTime: Date) -> Data? { guard let data = line.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any], parsed.count >= 3, let eventType = parsed[1] as? String, - let eventData = parsed[2] as? String else { + let eventData = parsed[2] as? String + else { return nil } - + let currentTime = Date() let timestamp = currentTime.timeIntervalSince(startTime) - + let event: [Any] = [timestamp, eventType, eventData] return try? JSONSerialization.data(withJSONObject: event) } @@ -226,15 +235,15 @@ enum CastFileError: LocalizedError { case fileNotFound(String) case invalidFormat case encodingError - + var errorDescription: String? { switch self { case .fileNotFound(let path): - return "Stream file not found: \(path)" + "Stream file not found: \(path)" case .invalidFormat: - return "Invalid stream file format" + "Invalid stream file format" case .encodingError: - return "Failed to encode cast file" + "Failed to encode cast file" } } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/DashboardKeychain.swift b/VibeTunnel/Core/Services/DashboardKeychain.swift index 53437af4..2abe06f1 100644 --- a/VibeTunnel/Core/Services/DashboardKeychain.swift +++ b/VibeTunnel/Core/Services/DashboardKeychain.swift @@ -1,18 +1,18 @@ import Foundation -import Security import os +import Security /// Service for managing dashboard password in keychain @MainActor final class DashboardKeychain { static let shared = DashboardKeychain() - + private let service = "sh.vibetunnel.vibetunnel" private let account = "dashboard-password" private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DashboardKeychain") - + private init() {} - + /// Get the dashboard password from keychain func getPassword() -> String? { let query: [String: Any] = [ @@ -21,21 +21,22 @@ final class DashboardKeychain { kSecAttrAccount as String: account, kSecReturnData as String: true ] - + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - + guard status == errSecSuccess, let data = result as? Data, - let password = String(data: data, encoding: .utf8) else { + let password = String(data: data, encoding: .utf8) + else { logger.debug("No password found in keychain") return nil } - + logger.debug("Password retrieved from keychain") return password } - + /// Check if a password exists without retrieving it (won't trigger keychain prompt) func hasPassword() -> Bool { let query: [String: Any] = [ @@ -46,47 +47,50 @@ final class DashboardKeychain { kSecReturnAttributes as String: false, kSecReturnData as String: false ] - + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - + return status == errSecSuccess } - + /// Set the dashboard password in keychain func setPassword(_ password: String) -> Bool { guard !password.isEmpty else { logger.warning("Attempted to set empty password") return false } - - let data = password.data(using: .utf8)! - + + guard let data = password.data(using: .utf8) else { + logger.warning("Failed to convert password to UTF-8 data") + return false + } + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ] - + // Try to update first var status = SecItemUpdate( query as CFDictionary, [kSecValueData as String: data] as CFDictionary ) - + if status == errSecItemNotFound { // Item doesn't exist, create it var addQuery = query addQuery[kSecValueData as String] = data status = SecItemAdd(addQuery as CFDictionary, nil) } - + let success = status == errSecSuccess logger.info("Password \(success ? "saved to" : "failed to save to") keychain") return success } - + /// Delete the dashboard password from keychain func deletePassword() -> Bool { let query: [String: Any] = [ @@ -94,10 +98,10 @@ final class DashboardKeychain { kSecAttrService as String: service, kSecAttrAccount as String: account ] - + let status = SecItemDelete(query as CFDictionary) let success = status == errSecSuccess || status == errSecItemNotFound logger.info("Password \(success ? "deleted from" : "failed to delete from") keychain") return success } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/HummingbirdServer.swift b/VibeTunnel/Core/Services/HummingbirdServer.swift index 188b3b53..108f2694 100644 --- a/VibeTunnel/Core/Services/HummingbirdServer.swift +++ b/VibeTunnel/Core/Services/HummingbirdServer.swift @@ -1,12 +1,5 @@ -// -// HummingbirdServer.swift -// VibeTunnel -// -// Hummingbird-based HTTP server implementation -// - -import Foundation import Combine +import Foundation import Hummingbird import OSLog @@ -16,13 +9,13 @@ final class HummingbirdServer: ServerProtocol { private var tunnelServer: TunnelServer? private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "HummingbirdServer") private let logSubject = PassthroughSubject() - + var serverType: ServerMode { .hummingbird } - + var isRunning: Bool { tunnelServer?.isRunning ?? false } - + var port: String = "4020" { didSet { // If server is running and port changed, we need to restart @@ -33,65 +26,83 @@ final class HummingbirdServer: ServerProtocol { } } } - + var logPublisher: AnyPublisher { logSubject.eraseToAnyPublisher() } - + func start() async throws { guard !isRunning else { logger.warning("Hummingbird server already running") return } - + logger.info("Starting Hummingbird server on port \(self.port)") - logSubject.send(ServerLogEntry(level: .info, message: "Initializing Hummingbird server...", source: .hummingbird)) - + logSubject.send(ServerLogEntry( + level: .info, + message: "Initializing Hummingbird server...", + source: .hummingbird + )) + do { - let portInt = Int(port) ?? 4020 + let portInt = Int(port) ?? 4_020 let bindAddress = ServerManager.shared.bindAddress let server = TunnelServer(port: portInt, bindAddress: bindAddress) tunnelServer = server - + try await server.start() - + logger.info("Hummingbird server started successfully") logSubject.send(ServerLogEntry(level: .info, message: "Hummingbird server is ready", source: .hummingbird)) - } catch { logger.error("Failed to start Hummingbird server: \(error.localizedDescription)") - logSubject.send(ServerLogEntry(level: .error, message: "Failed to start: \(error.localizedDescription)", source: .hummingbird)) + logSubject.send(ServerLogEntry( + level: .error, + message: "Failed to start: \(error.localizedDescription)", + source: .hummingbird + )) throw error } } - + func stop() async { guard let server = tunnelServer, isRunning else { logger.warning("Hummingbird server not running") return } - + logger.info("Stopping Hummingbird server") - logSubject.send(ServerLogEntry(level: .info, message: "Shutting down Hummingbird server...", source: .hummingbird)) - + logSubject.send(ServerLogEntry( + level: .info, + message: "Shutting down Hummingbird server...", + source: .hummingbird + )) + do { try await server.stop() tunnelServer = nil - + logger.info("Hummingbird server stopped") - logSubject.send(ServerLogEntry(level: .info, message: "Hummingbird server shutdown complete", source: .hummingbird)) - + logSubject.send(ServerLogEntry( + level: .info, + message: "Hummingbird server shutdown complete", + source: .hummingbird + )) } catch { logger.error("Error stopping Hummingbird server: \(error.localizedDescription)") - logSubject.send(ServerLogEntry(level: .error, message: "Error stopping: \(error.localizedDescription)", source: .hummingbird)) + logSubject.send(ServerLogEntry( + level: .error, + message: "Error stopping: \(error.localizedDescription)", + source: .hummingbird + )) } } - + func restart() async throws { logger.info("Restarting Hummingbird server") logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .hummingbird)) - + await stop() try await start() } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/NgrokService.swift b/VibeTunnel/Core/Services/NgrokService.swift index 9ca97645..33ef48fb 100644 --- a/VibeTunnel/Core/Services/NgrokService.swift +++ b/VibeTunnel/Core/Services/NgrokService.swift @@ -81,7 +81,7 @@ final class NgrokService: NgrokTunnelProtocol { } } } - + /// Check if auth token exists without triggering keychain prompt var hasAuthToken: Bool { KeychainHelper.hasNgrokAuthToken() @@ -148,7 +148,7 @@ final class NgrokService: NgrokTunnelProtocol { let checkProcess = Process() checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which") checkProcess.arguments = ["ngrok"] - + // Add common Homebrew paths to PATH for the check var environment = ProcessInfo.processInfo.environment let currentPath = environment["PATH"] ?? "/usr/bin:/bin" @@ -174,7 +174,10 @@ final class NgrokService: NgrokTunnelProtocol { // Set up ngrok with auth token let authProcess = Process() authProcess.executableURL = URL(fileURLWithPath: ngrokPath) - authProcess.arguments = ["config", "add-authtoken", authToken!] + guard let authToken else { + throw NgrokError.authTokenMissing + } + authProcess.arguments = ["config", "add-authtoken", authToken] try authProcess.run() authProcess.waitUntilExit() @@ -232,7 +235,6 @@ final class NgrokService: NgrokTunnelProtocol { logger.info("ngrok tunnel started: \(url)") return url - } catch { logger.error("Failed to start ngrok: \(error)") throw error @@ -286,7 +288,9 @@ final class NgrokService: NgrokTunnelProtocol { throw NgrokError.networkError("Operation timed out") } - let result = try await group.next()! + guard let result = try await group.next() else { + throw NgrokError.networkError("No result received") + } group.cancelAll() return result } @@ -313,7 +317,8 @@ struct AsyncLineSequence: AsyncSequence { mutating func next() async -> String? { while true { - if let range = buffer.range(of: "\n".data(using: .utf8)!) { + let lineBreakData = Data("\n".utf8) + if let range = buffer.range(of: lineBreakData) { let line = String(data: buffer[.. Bool { let query: [String: Any] = [ @@ -376,15 +381,17 @@ private enum KeychainHelper { kSecReturnAttributes as String: false, kSecReturnData as String: false ] - + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - + return status == errSecSuccess } static func setNgrokAuthToken(_ token: String) { - let data = token.data(using: .utf8)! + guard let data = token.data(using: .utf8) else { + return + } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/VibeTunnel/Core/Services/RustServer.swift b/VibeTunnel/Core/Services/RustServer.swift index e595047c..c24fa787 100644 --- a/VibeTunnel/Core/Services/RustServer.swift +++ b/VibeTunnel/Core/Services/RustServer.swift @@ -1,21 +1,12 @@ -// -// RustServer.swift -// VibeTunnel -// -// Rust tty-fwd binary server implementation -// - -import Foundation import Combine +import Foundation import OSLog /// Task tracking for better debugging enum ServerTaskContext { - @TaskLocal - static var taskName: String? - - @TaskLocal - static var serverType: ServerMode? + @TaskLocal static var taskName: String? + + @TaskLocal static var serverType: ServerMode? } /// Rust tty-fwd server implementation @@ -26,15 +17,18 @@ final class RustServer: ServerProtocol { private var stderrPipe: Pipe? private var outputTask: Task? private var errorTask: Task? - + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "RustServer") private let logSubject = PassthroughSubject() private let processQueue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer", qos: .userInitiated) - - // Actor to handle process operations on background thread + + /// Actor to handle process operations on background thread private actor ProcessHandler { - private let queue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer.ProcessHandler", qos: .userInitiated) - + private let queue = DispatchQueue( + label: "com.steipete.VibeTunnel.RustServer.ProcessHandler", + qos: .userInitiated + ) + func runProcess(_ process: Process) async throws { try await withCheckedThrowingContinuation { continuation in queue.async { @@ -47,7 +41,7 @@ final class RustServer: ServerProtocol { } } } - + func waitForExit(_ process: Process) async { await withCheckedContinuation { continuation in queue.async { @@ -56,7 +50,7 @@ final class RustServer: ServerProtocol { } } } - + func terminateProcess(_ process: Process) async { await withCheckedContinuation { continuation in queue.async { @@ -66,13 +60,13 @@ final class RustServer: ServerProtocol { } } } - + private let processHandler = ProcessHandler() - + var serverType: ServerMode { .rust } - + private(set) var isRunning = false - + var port: String = "" { didSet { // If server is running and port changed, we need to restart @@ -83,76 +77,76 @@ final class RustServer: ServerProtocol { } } } - + var logPublisher: AnyPublisher { logSubject.eraseToAnyPublisher() } - + func start() async throws { guard !isRunning else { logger.warning("Rust server already running") return } - + guard !port.isEmpty else { let error = RustServerError.invalidPort logger.error("Port not configured") logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust)) throw error } - + logger.info("Starting Rust tty-fwd server on port \(self.port)") logSubject.send(ServerLogEntry(level: .info, message: "Initializing Rust tty-fwd server...", source: .rust)) - + // Get the tty-fwd binary path let binaryPath = Bundle.main.path(forResource: "tty-fwd", ofType: nil) - guard let binaryPath = binaryPath else { + guard let binaryPath else { let error = RustServerError.binaryNotFound logger.error("tty-fwd binary not found in bundle") logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust)) throw error } - + // Ensure binary is executable try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath) - + // Verify binary exists and is executable var isDirectory: ObjCBool = false let fileExists = FileManager.default.fileExists(atPath: binaryPath, isDirectory: &isDirectory) logger.info("tty-fwd binary exists: \(fileExists), is directory: \(isDirectory.boolValue)") - + if fileExists && !isDirectory.boolValue { let attributes = try FileManager.default.attributesOfItem(atPath: binaryPath) if let permissions = attributes[.posixPermissions] as? NSNumber { logger.info("tty-fwd binary permissions: \(String(permissions.intValue, radix: 8))") } } - + // Create the process using login shell let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/zsh") - + // Get the Resources directory path let bundlePath = Bundle.main.bundlePath let resourcesPath = Bundle.main.resourcePath ?? bundlePath - + // Set working directory to Resources directory where both tty-fwd and web folder exist process.currentDirectoryURL = URL(fileURLWithPath: resourcesPath) logger.info("Setting working directory to: \(resourcesPath)") - + // The web/public directory should be at web/public relative to Resources let webPublicPath = URL(fileURLWithPath: resourcesPath).appendingPathComponent("web/public") let webPublicExists = FileManager.default.fileExists(atPath: webPublicPath.path) logger.info("Web public directory at \(webPublicPath.path) exists: \(webPublicExists)") - + // Use absolute path for static directory let staticPath = webPublicPath.path - + // Build command to run tty-fwd through login shell // Use bind address from ServerManager to control server accessibility let bindAddress = ServerManager.shared.bindAddress var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)" - + // Add password flag if password protection is enabled if let password = DashboardKeychain.shared.getPassword() { // Escape the password for shell @@ -163,64 +157,68 @@ final class RustServer: ServerProtocol { ttyFwdCommand += " --password \"\(escapedPassword)\"" } process.arguments = ["-l", "-c", ttyFwdCommand] - + logger.info("Executing command: /bin/zsh -l -c \"\(ttyFwdCommand)\"") logger.info("Working directory: \(resourcesPath)") - + // Set up environment - login shell will load the rest var environment = ProcessInfo.processInfo.environment environment["RUST_LOG"] = "info" process.environment = environment - + // Set up pipes for stdout and stderr let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe - + self.process = process self.stdoutPipe = stdoutPipe self.stderrPipe = stderrPipe - + // Start monitoring output startOutputMonitoring() - + // Start the process on background thread do { try await processHandler.runProcess(process) - + isRunning = true - + // Give the server a moment to start try await Task.sleep(for: .seconds(1)) - + // Check if process is still running if !process.isRunning { logger.error("Process terminated with exit code: \(process.terminationStatus)") - + // Try to read any error output if let stderrPipe = self.stderrPipe { let errorData = stderrPipe.fileHandleForReading.availableData if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) { logger.error("Process stderr: \(errorOutput)") - logSubject.send(ServerLogEntry(level: .error, message: "Process error: \(errorOutput)", source: .rust)) + logSubject.send(ServerLogEntry( + level: .error, + message: "Process error: \(errorOutput)", + source: .rust + )) } } - + throw RustServerError.processFailedToStart } - + logger.info("Rust server process started, performing health check...") logSubject.send(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust)) - + // Perform health check to ensure server is actually responding let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5) - + if isHealthy { logger.info("Rust server started successfully and is responding") logSubject.send(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .rust)) logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server is ready", source: .rust)) - + // Monitor process termination with task context Task { await ServerTaskContext.$taskName.withValue("RustServer-monitor-\(port)") { @@ -232,54 +230,65 @@ final class RustServer: ServerProtocol { } else { // Server process is running but not responding logger.error("Rust server process started but is not responding to health checks") - logSubject.send(ServerLogEntry(level: .error, message: "Health check failed - server not responding", source: .rust)) - + logSubject.send(ServerLogEntry( + level: .error, + message: "Health check failed - server not responding", + source: .rust + )) + // Clean up the non-responsive process process.terminate() self.process = nil self.stdoutPipe = nil self.stderrPipe = nil isRunning = false - + throw RustServerError.serverNotResponding } - } catch { isRunning = false logger.error("Failed to start Rust server: \(error.localizedDescription)") - logSubject.send(ServerLogEntry(level: .error, message: "Failed to start: \(error.localizedDescription)", source: .rust)) + logSubject.send(ServerLogEntry( + level: .error, + message: "Failed to start: \(error.localizedDescription)", + source: .rust + )) throw error } } - + func stop() async { - guard let process = process, isRunning else { + guard let process, isRunning else { logger.warning("Rust server not running") return } - + logger.info("Stopping Rust server") logSubject.send(ServerLogEntry(level: .info, message: "Shutting down Rust tty-fwd server...", source: .rust)) - + // Cancel output monitoring tasks outputTask?.cancel() errorTask?.cancel() - + // Terminate the process on background thread await processHandler.terminateProcess(process) - + // Wait for process to terminate (with timeout) let terminated: Void? = await withTimeoutOrNil(seconds: 5) { [self] in await self.processHandler.waitForExit(process) } - + if terminated == nil { // Force kill if termination timeout process.interrupt() logger.warning("Force killed Rust server after timeout") - logSubject.send(ServerLogEntry(level: .warning, message: "Force killed server after timeout", source: .rust)) + logSubject.send(ServerLogEntry( + level: .warning, + message: "Force killed server after timeout", + source: .rust + )) } - + // Clean up self.process = nil self.stdoutPipe = nil @@ -287,38 +296,40 @@ final class RustServer: ServerProtocol { self.outputTask = nil self.errorTask = nil isRunning = false - + logger.info("Rust server stopped") logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server shutdown complete", source: .rust)) } - + func restart() async throws { logger.info("Restarting Rust server") logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .rust)) - + await stop() try await start() } - + // MARK: - Private Methods - + private func performHealthCheck(maxAttempts: Int, delaySeconds: Double) async -> Bool { - let healthURL = URL(string: "http://127.0.0.1:\(port)/api/health")! - + guard let healthURL = URL(string: "http://127.0.0.1:\(port)/api/health") else { + return false + } + for attempt in 1...maxAttempts { do { // Create request with short timeout var request = URLRequest(url: healthURL) request.timeoutInterval = 2.0 - + logSubject.send(ServerLogEntry( level: .debug, message: "Health check attempt \(attempt)/\(maxAttempts)...", source: .rust )) - + let (_, response) = try await URLSession.shared.data(for: request) - + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { logger.debug("Health check succeeded on attempt \(attempt)") return true @@ -333,43 +344,44 @@ final class RustServer: ServerProtocol { )) } } - + // Wait before next attempt (except on last attempt) if attempt < maxAttempts { try? await Task.sleep(for: .seconds(delaySeconds)) } } - + return false } - + private func startOutputMonitoring() { // Capture pipes and port before starting detached tasks let stdoutPipe = self.stdoutPipe let stderrPipe = self.stderrPipe let currentPort = self.port - + // Monitor stdout on background thread outputTask = Task.detached { [weak self] in ServerTaskContext.$taskName.withValue("RustServer-stdout-\(currentPort)") { ServerTaskContext.$serverType.withValue(.rust) { - guard let self = self, let pipe = stdoutPipe else { return } - + guard let self, let pipe = stdoutPipe else { return } + let handle = pipe.fileHandleForReading self.logger.debug("Starting stdout monitoring for Rust server on port \(currentPort)") - + while !Task.isCancelled { autoreleasepool { let data = handle.availableData if !data.isEmpty, let output = String(data: data, encoding: .utf8) { - let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) + let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) for line in lines where !line.isEmpty { // Skip shell initialization messages if line.contains("zsh:") || line.hasPrefix("Last login:") { continue } Task { @MainActor [weak self] in - guard let self = self else { return } + guard let self else { return } let level = self.detectLogLevel(from: line) self.logSubject.send(ServerLogEntry(level: level, message: line, source: .rust)) } @@ -377,52 +389,57 @@ final class RustServer: ServerProtocol { } } } - + self.logger.debug("Stopped stdout monitoring for Rust server") } } } - + // Monitor stderr on background thread errorTask = Task.detached { [weak self] in ServerTaskContext.$taskName.withValue("RustServer-stderr-\(currentPort)") { ServerTaskContext.$serverType.withValue(.rust) { - guard let self = self, let pipe = stderrPipe else { return } - + guard let self, let pipe = stderrPipe else { return } + let handle = pipe.fileHandleForReading self.logger.debug("Starting stderr monitoring for Rust server on port \(currentPort)") - + while !Task.isCancelled { autoreleasepool { let data = handle.availableData if !data.isEmpty, let output = String(data: data, encoding: .utf8) { - let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) + let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) for line in lines where !line.isEmpty { // Skip shell initialization messages if line.contains("zsh:") || line.hasPrefix("Last login:") { continue } Task { @MainActor [weak self] in - guard let self = self else { return } - self.logSubject.send(ServerLogEntry(level: .error, message: line, source: .rust)) + guard let self else { return } + self.logSubject.send(ServerLogEntry( + level: .error, + message: line, + source: .rust + )) } } } } } - + self.logger.debug("Stopped stderr monitoring for Rust server") } } } } - + private func monitorProcessTermination() async { - guard let process = process else { return } - + guard let process else { return } + // Wait for process exit on background thread await processHandler.waitForExit(process) - + if self.isRunning { // Unexpected termination let exitCode = process.terminationStatus @@ -432,9 +449,9 @@ final class RustServer: ServerProtocol { message: "Server terminated unexpectedly with exit code: \(exitCode)", source: .rust )) - + self.isRunning = false - + // Auto-restart on unexpected termination Task { try? await Task.sleep(for: .seconds(2)) @@ -450,7 +467,7 @@ final class RustServer: ServerProtocol { } } } - + private func detectLogLevel(from line: String) -> ServerLogEntry.Level { let lowercased = line.lowercased() if lowercased.contains("error") || lowercased.contains("fatal") { @@ -463,21 +480,26 @@ final class RustServer: ServerProtocol { return .info } } - - private func withTimeoutOrNil(seconds: TimeInterval, operation: @escaping @Sendable () async -> T) async -> T? { + + private func withTimeoutOrNil( + seconds: TimeInterval, + operation: @escaping @Sendable () async -> T + ) + async -> T? + { await withTaskGroup(of: T?.self) { group in group.addTask { await operation() } - + group.addTask { try? await Task.sleep(for: .seconds(seconds)) return nil } - + let result = await group.next() group.cancelAll() - return result ?? nil + return result } } } @@ -489,17 +511,17 @@ enum RustServerError: LocalizedError { case processFailedToStart case serverNotResponding case invalidPort - + var errorDescription: String? { switch self { case .binaryNotFound: - return "The tty-fwd binary was not found in the app bundle" + "The tty-fwd binary was not found in the app bundle" case .processFailedToStart: - return "The server process failed to start" + "The server process failed to start" case .serverNotResponding: - return "The server process started but is not responding to health checks" + "The server process started but is not responding to health checks" case .invalidPort: - return "Server port is not configured" + "Server port is not configured" } } } diff --git a/VibeTunnel/Core/Services/ServerManager.swift b/VibeTunnel/Core/Services/ServerManager.swift index 10060e87..4edc60bc 100644 --- a/VibeTunnel/Core/Services/ServerManager.swift +++ b/VibeTunnel/Core/Services/ServerManager.swift @@ -1,69 +1,64 @@ -// -// ServerManager.swift -// VibeTunnel -// -// Manages server lifecycle and switching between server modes -// - -import Foundation -import SwiftUI import Combine -import OSLog +import Foundation import Observation +import OSLog +import SwiftUI /// Manages the active server and handles switching between modes @MainActor @Observable class ServerManager { static let shared = ServerManager() - + private var serverModeString: String { get { UserDefaults.standard.string(forKey: "serverMode") ?? ServerMode.rust.rawValue } set { UserDefaults.standard.set(newValue, forKey: "serverMode") } } - + var port: String { get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" } set { UserDefaults.standard.set(newValue, forKey: "serverPort") } } - + var bindAddress: String { - get { - let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "") ?? .localhost + get { + let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" + ) ?? + .localhost return mode.bindAddress } - set { + set { // Find the mode that matches this bind address if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) { UserDefaults.standard.set(mode.rawValue, forKey: "dashboardAccessMode") } } } - + private var cleanupOnStartup: Bool { get { UserDefaults.standard.bool(forKey: "cleanupOnStartup") } set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") } } - + private(set) var currentServer: ServerProtocol? private(set) var isRunning = false private(set) var isSwitching = false private(set) var lastError: Error? - + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager") private var cancellables = Set() private let logSubject = PassthroughSubject() - + var serverMode: ServerMode { get { ServerMode(rawValue: serverModeString) ?? .rust } set { serverModeString = newValue.rawValue } } - + var logPublisher: AnyPublisher { logSubject.eraseToAnyPublisher() } - - // Modern async stream for logs + + /// Modern async stream for logs var logStream: AsyncStream { AsyncStream { continuation in // Use logPublisher directly without storing the cancellable @@ -75,11 +70,11 @@ class ServerManager { } } } - + private init() { setupObservers() } - + private func setupObservers() { // Watch for server mode changes when the value actually changes // Since we're using @AppStorage, we need to observe changes differently @@ -91,18 +86,18 @@ class ServerManager { } .store(in: &cancellables) } - + /// Start the server with current configuration func start() async { // Check if we already have a running server if let existingServer = currentServer { logger.info("Server already running on port \(existingServer.port)") - + // Ensure our state is synced isRunning = true lastError = nil ServerMonitor.shared.isServerRunning = true - + // Log for clarity logSubject.send(ServerLogEntry( level: .info, @@ -111,39 +106,38 @@ class ServerManager { )) return } - + // Log that we're starting a server logSubject.send(ServerLogEntry( level: .info, message: "Starting \(serverMode.displayName) server on port \(port)...", source: serverMode )) - + do { let server = createServer(for: serverMode) server.port = port - + // Subscribe to server logs server.logPublisher .sink { [weak self] entry in self?.logSubject.send(entry) } .store(in: &cancellables) - + try await server.start() - + currentServer = server isRunning = true lastError = nil - + logger.info("Started \(self.serverMode.displayName) server on port \(self.port)") - + // Update ServerMonitor for compatibility ServerMonitor.shared.isServerRunning = true - + // Trigger cleanup of old sessions after server starts await triggerInitialCleanup() - } catch { logger.error("Failed to start server: \(error.localizedDescription)") logSubject.send(ServerLogEntry( @@ -152,7 +146,7 @@ class ServerManager { source: serverMode )) lastError = error - + // Check if server is actually running despite the error if let server = currentServer, server.isRunning { logger.warning("Server reported as running despite startup error, syncing state") @@ -164,55 +158,55 @@ class ServerManager { } } } - + /// Stop the current server func stop() async { guard let server = currentServer else { logger.warning("No server running") return } - + let serverType = server.serverType logger.info("Stopping \(serverType.displayName) server") - + // Log that we're stopping the server logSubject.send(ServerLogEntry( level: .info, message: "Stopping \(serverType.displayName) server...", source: serverType )) - + await server.stop() currentServer = nil isRunning = false - + // Log that the server has stopped logSubject.send(ServerLogEntry( level: .info, message: "\(serverType.displayName) server stopped", source: serverType )) - + // Update ServerMonitor for compatibility ServerMonitor.shared.isServerRunning = false } - + /// Restart the current server func restart() async { await stop() await start() } - + /// Switch to a different server mode func switchMode(to mode: ServerMode) async { guard mode != serverMode else { return } - + isSwitching = true defer { isSwitching = false } - + let oldMode = serverMode logger.info("Switching from \(oldMode.displayName) to \(mode.displayName)") - + // Log the mode switch with a clear separator logSubject.send(ServerLogEntry( level: .info, @@ -229,21 +223,21 @@ class ServerManager { message: "════════════════════════════════════════════════════════", source: oldMode )) - + // Stop current server if running if currentServer != nil { await stop() } - + // Add a small delay for visual clarity in logs try? await Task.sleep(for: .milliseconds(500)) - + // Update mode serverMode = mode - + // Start new server await start() - + // Log completion logSubject.send(ServerLogEntry( level: .info, @@ -261,7 +255,7 @@ class ServerManager { source: mode )) } - + private func handleServerModeChange() async { // This is called when serverMode changes via AppStorage // If we have a running server, switch to the new mode @@ -269,16 +263,16 @@ class ServerManager { await switchMode(to: serverMode) } } - + private func createServer(for mode: ServerMode) -> ServerProtocol { switch mode { case .hummingbird: - return HummingbirdServer() + HummingbirdServer() case .rust: - return RustServer() + RustServer() } } - + /// Trigger cleanup of exited sessions after server startup private func triggerInitialCleanup() async { // Check if cleanup on startup is enabled @@ -286,27 +280,31 @@ class ServerManager { logger.info("Cleanup on startup is disabled in settings") return } - + logger.info("Triggering initial cleanup of exited sessions") - + // Small delay to ensure server is fully ready try? await Task.sleep(for: .milliseconds(500)) - + do { // Create URL for cleanup endpoint - let url = URL(string: "http://localhost:\(port)/api/cleanup-exited")! + guard let url = URL(string: "http://localhost:\(port)/api/cleanup-exited") else { + logger.warning("Failed to create cleanup URL") + return + } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 10 - + // Make the cleanup request let (data, response) = try await URLSession.shared.data(for: request) - + if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { // Try to parse the response if let jsonData = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let cleanedCount = jsonData["cleaned_count"] as? Int { + let cleanedCount = jsonData["cleaned_count"] as? Int + { logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions") logSubject.send(ServerLogEntry( level: .info, @@ -335,4 +333,4 @@ class ServerManager { )) } } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/ServerMonitor.swift b/VibeTunnel/Core/Services/ServerMonitor.swift index d850509d..871f8eb8 100644 --- a/VibeTunnel/Core/Services/ServerMonitor.swift +++ b/VibeTunnel/Core/Services/ServerMonitor.swift @@ -8,25 +8,24 @@ import Observation public final class ServerMonitor { public static let shared = ServerMonitor() - // Observable properties + /// Observable properties public var isRunning: Bool { isServerRunning } - + public var port: Int { - Int(ServerManager.shared.port) ?? 4020 + Int(ServerManager.shared.port) ?? 4_020 } - + public var lastError: Error? { ServerManager.shared.lastError } /// Reference to the actual server (kept for backward compatibility) private weak var server: TunnelServer? - + /// Internal state tracking - @ObservationIgnored - public var isServerRunning = false { + @ObservationIgnored public var isServerRunning = false { didSet { // Notify observers when state changes } @@ -51,7 +50,7 @@ public final class ServerMonitor { await syncWithServerManager() } } - + /// Syncs state with ServerManager private func syncWithServerManager() async { isServerRunning = ServerManager.shared.isRunning @@ -70,7 +69,7 @@ public final class ServerMonitor { await ServerManager.shared.stop() await syncWithServerManager() } - + /// Restarts the server public func restartServer() async throws { await ServerManager.shared.restart() @@ -82,7 +81,9 @@ public final class ServerMonitor { guard isRunning else { return false } do { - let url = URL(string: "http://127.0.0.1:\(port)/api/health")! + guard let url = URL(string: "http://127.0.0.1:\(port)/api/health") else { + return false + } let request = URLRequest(url: url, timeoutInterval: 2.0) let (_, response) = try await URLSession.shared.data(for: request) diff --git a/VibeTunnel/Core/Services/ServerProtocol.swift b/VibeTunnel/Core/Services/ServerProtocol.swift index dcb6d4e7..2c6e06de 100644 --- a/VibeTunnel/Core/Services/ServerProtocol.swift +++ b/VibeTunnel/Core/Services/ServerProtocol.swift @@ -1,58 +1,51 @@ -// -// ServerProtocol.swift -// VibeTunnel -// -// Protocol defining the interface for different server implementations -// - -import Foundation import Combine +import Foundation /// Common interface for server implementations @MainActor protocol ServerProtocol: AnyObject { /// Current running state of the server var isRunning: Bool { get } - + /// Port the server is configured to use var port: String { get set } - + /// Server type identifier var serverType: ServerMode { get } - + /// Start the server func start() async throws - + /// Stop the server func stop() async - + /// Restart the server func restart() async throws - + /// Publisher for streaming log messages var logPublisher: AnyPublisher { get } } /// Server mode options enum ServerMode: String, CaseIterable { - case hummingbird = "hummingbird" - case rust = "rust" - + case hummingbird + case rust + var displayName: String { switch self { case .hummingbird: - return "Hummingbird" + "Hummingbird" case .rust: - return "Rust" + "Rust" } } - + var description: String { switch self { case .hummingbird: - return "Built-in Swift server" + "Built-in Swift server" case .rust: - return "External tty-fwd binary" + "External tty-fwd binary" } } } @@ -65,16 +58,16 @@ struct ServerLogEntry { case warning case error } - + let timestamp: Date let level: Level let message: String let source: ServerMode - + init(level: Level = .info, message: String, source: ServerMode) { self.timestamp = Date() self.level = level self.message = message self.source = source } -} \ No newline at end of file +} diff --git a/VibeTunnel/Core/Services/SessionMonitor.swift b/VibeTunnel/Core/Services/SessionMonitor.swift index fe29bc18..98f82b76 100644 --- a/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/VibeTunnel/Core/Services/SessionMonitor.swift @@ -80,7 +80,12 @@ class SessionMonitor { private func fetchSessions() async { do { // First check if server is running - let healthURL = URL(string: "http://127.0.0.1:\(serverPort)/api/health")! + guard let healthURL = URL(string: "http://127.0.0.1:\(serverPort)/api/health") else { + self.sessions = [:] + self.sessionCount = 0 + self.lastError = nil + return + } let healthRequest = URLRequest(url: healthURL, timeoutInterval: 2.0) do { @@ -103,7 +108,10 @@ class SessionMonitor { } // Server is running, fetch sessions - let url = URL(string: "http://127.0.0.1:\(serverPort)/sessions")! + guard let url = URL(string: "http://127.0.0.1:\(serverPort)/sessions") else { + self.lastError = "Invalid URL" + return + } let request = URLRequest(url: url, timeoutInterval: 5.0) let (data, response) = try await URLSession.shared.data(for: request) @@ -119,9 +127,8 @@ class SessionMonitor { self.sessions = sessionsData // Count only running sessions - self.sessionCount = sessionsData.values.count(where: { $0.isRunning }) + self.sessionCount = sessionsData.values.count { $0.isRunning } self.lastError = nil - } catch { // Don't set error for connection issues when server is likely not running if !(error is URLError) { diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index 604e24c6..568bd823 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -27,45 +27,45 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate { // Initialize Sparkle with standard configuration #if DEBUG - // In debug mode, don't start the updater automatically - updaterController = SPUStandardUpdaterController( - startingUpdater: false, - updaterDelegate: self, - userDriverDelegate: nil - ) + // In debug mode, don't start the updater automatically + updaterController = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil + ) #else - updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: self, - userDriverDelegate: nil - ) + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: nil + ) #endif // Configure automatic updates if let updater = updaterController?.updater { #if DEBUG - // Disable automatic checks in debug builds - updater.automaticallyChecksForUpdates = false - updater.automaticallyDownloadsUpdates = false - logger.info("Sparkle updater initialized in DEBUG mode - automatic updates disabled") + // Disable automatic checks in debug builds + updater.automaticallyChecksForUpdates = false + updater.automaticallyDownloadsUpdates = false + logger.info("Sparkle updater initialized in DEBUG mode - automatic updates disabled") #else - // Enable automatic checking for updates - updater.automaticallyChecksForUpdates = true + // Enable automatic checking for updates + updater.automaticallyChecksForUpdates = true - // Enable automatic downloading of updates - updater.automaticallyDownloadsUpdates = true + // Enable automatic downloading of updates + updater.automaticallyDownloadsUpdates = true - // Set update check interval to 24 hours - updater.updateCheckInterval = 86_400 + // Set update check interval to 24 hours + updater.updateCheckInterval = 86_400 - logger.info("Sparkle updater initialized successfully with automatic downloads enabled") - - // Start the updater if it wasn't started during initialization - if !updaterController!.startedUpdater { - updaterController!.updater.startUpdater() - } + logger.info("Sparkle updater initialized successfully with automatic downloads enabled") + + // Start the updater if it wasn't started during initialization + if let controller = updaterController, !controller.startedUpdater { + controller.updater.startUpdater() + } #endif - + // Note: feedURL configuration happens through delegate methods } } @@ -74,7 +74,7 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate { // Save the channel preference UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel") logger.info("Update channel set to: \(channel.rawValue)") - + // The actual feed URL will be provided by the delegate method } @@ -115,24 +115,26 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate { // MARK: - SPUUpdaterDelegate extension SparkleUpdaterManager { - nonisolated public func updater(_ updater: SPUUpdater, mayPerformUpdateCheck updateCheck: SPUUpdateCheck) throws { + public nonisolated func updater(_ updater: SPUUpdater, mayPerformUpdateCheck updateCheck: SPUUpdateCheck) throws { // Allow update checks by default - not throwing an error means the check is allowed // We could add logic here to prevent checks during certain conditions } - - nonisolated public func allowedChannels(for updater: SPUUpdater) -> Set { + + public nonisolated func allowedChannels(for updater: SPUUpdater) -> Set { // Get the current update channel from UserDefaults if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"), - let channel = UpdateChannel(rawValue: savedChannel) { + let channel = UpdateChannel(rawValue: savedChannel) + { return channel.includesPreReleases ? Set(["", "prerelease"]) : Set([""]) } return Set([""]) // Default to stable channel only } - - nonisolated public func feedURLString(for updater: SPUUpdater) -> String? { + + public nonisolated func feedURLString(for updater: SPUUpdater) -> String? { // Provide the appropriate feed URL based on the current update channel if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"), - let channel = UpdateChannel(rawValue: savedChannel) { + let channel = UpdateChannel(rawValue: savedChannel) + { return channel.appcastURL.absoluteString } return UpdateChannel.defaultChannel.appcastURL.absoluteString @@ -167,7 +169,8 @@ public final class SparkleViewModel { // Load saved update channel if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"), - let channel = UpdateChannel(rawValue: savedChannel) { + let channel = UpdateChannel(rawValue: savedChannel) + { updateChannel = channel } else { updateChannel = UpdateChannel.stable @@ -190,7 +193,9 @@ extension ProcessInfo { fileprivate var installedFromAppStore: Bool { // Check for App Store receipt let receiptURL = Bundle.main.appStoreReceiptURL - return receiptURL?.lastPathComponent == "receipt" && FileManager.default - .fileExists(atPath: receiptURL?.path ?? "") + if let receiptURL { + return receiptURL.lastPathComponent == "receipt" && FileManager.default.fileExists(atPath: receiptURL.path) + } + return false } } diff --git a/VibeTunnel/Core/Services/TTYForwardManager.swift b/VibeTunnel/Core/Services/TTYForwardManager.swift index 3f2e2989..eda06e01 100644 --- a/VibeTunnel/Core/Services/TTYForwardManager.swift +++ b/VibeTunnel/Core/Services/TTYForwardManager.swift @@ -59,13 +59,13 @@ final class TTYForwardManager { do { try process.run() - + // Set up a handler to log when the process terminates process.terminationHandler = { [weak self] process in self?.logger.info("tty-fwd process terminated with status: \(process.terminationStatus)") if process.terminationStatus != 0 { self?.logger.error("tty-fwd process failed with exit code: \(process.terminationStatus)") - + // Try to read stderr for error details if let errorPipe = process.standardError as? Pipe { let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() @@ -75,7 +75,7 @@ final class TTYForwardManager { } } } - + completion(.success(process)) } catch { logger.error("Failed to execute tty-fwd: \(error.localizedDescription)") diff --git a/VibeTunnel/Core/Services/TunnelClient.swift b/VibeTunnel/Core/Services/TunnelClient.swift index 7e2351e4..51536995 100644 --- a/VibeTunnel/Core/Services/TunnelClient.swift +++ b/VibeTunnel/Core/Services/TunnelClient.swift @@ -362,7 +362,7 @@ public enum TunnelClientError: LocalizedError, Equatable { } } - public static func == (lhs: TunnelClientError, rhs: TunnelClientError) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case (.invalidResponse, .invalidResponse): true diff --git a/VibeTunnel/Core/Services/TunnelServer.swift b/VibeTunnel/Core/Services/TunnelServer.swift index d9ca0d20..3cb03291 100644 --- a/VibeTunnel/Core/Services/TunnelServer.swift +++ b/VibeTunnel/Core/Services/TunnelServer.swift @@ -110,10 +110,9 @@ public final class TunnelServer { private var serverTask: Task? private let ttyFwdControlDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vibetunnel") .appendingPathComponent("control").path - private var bindAddress: String - + public init(port: Int = 4_020, bindAddress: String = "127.0.0.1") { self.port = port self.bindAddress = bindAddress @@ -124,13 +123,12 @@ public final class TunnelServer { logger.info("Starting TunnelServer on port \(port)") - do { let router = Router(context: BasicRequestContext.self) // Add middleware router.add(middleware: LogRequestsMiddleware(.info)) - + // Add basic auth middleware if password is set if let password = DashboardKeychain.shared.getPassword() { router.add(middleware: BasicAuthMiddleware(password: password)) @@ -149,7 +147,7 @@ public final class TunnelServer { "uptime": ProcessInfo.processInfo.systemUptime ] - let jsonData = try! JSONSerialization.data(withJSONObject: info) + let jsonData = (try? JSONSerialization.data(withJSONObject: info)) ?? Data() var buffer = ByteBuffer() buffer.writeBytes(jsonData) @@ -234,9 +232,12 @@ public final class TunnelServer { // Legacy endpoint for backwards compatibility router.get("/sessions") { _, _ async -> Response in - let process = await MainActor.run { - TTYForwardManager.shared.createTTYForwardProcess(with: ["--control-path", self.ttyFwdControlDir, "--list-sessions"]) + TTYForwardManager.shared.createTTYForwardProcess(with: [ + "--control-path", + self.ttyFwdControlDir, + "--list-sessions" + ]) } guard let process else { @@ -274,26 +275,28 @@ public final class TunnelServer { } else { // Read error output let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - + let errorString = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Provide more descriptive error messages based on exit code let statusCode = Int(process.terminationStatus) - let errorDescription: String - - switch statusCode { + let errorDescription: String = switch statusCode { case 9: - errorDescription = "Process was killed (SIGKILL). The control directory may not exist or be accessible." + "Process was killed (SIGKILL). The control directory may not exist or be accessible." case -9: - errorDescription = "Process was terminated by SIGKILL. This might be due to macOS security restrictions." + "Process was terminated by SIGKILL. This might be due to macOS security restrictions." default: - errorDescription = errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString + errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString } - + // Log additional debugging information self.logger.error("tty-fwd executable path: \(process.executableURL?.path ?? "unknown")") self.logger.error("Control directory path: \(self.ttyFwdControlDir)") - self.logger.error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))") - + self.logger + .error( + "Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))" + ) + self.logger.error("tty-fwd failed with status \(statusCode): \(errorDescription)") let errorJson = @@ -322,11 +325,11 @@ public final class TunnelServer { // Serve index.html from root path router.get("/") { _, _ async -> Response in - return await self.serveStaticFile(path: "index.html") + await self.serveStaticFile(path: "index.html") } // Serve static files from web/public folder (catch-all route - must be last) - router.get("**") { request, context async -> Response in + router.get("**") { request, _ async -> Response in // Get the full path from the request URI let requestPath = request.uri.path // Remove leading slash @@ -391,7 +394,6 @@ public final class TunnelServer { } else { throw ServerError.failedToStart("Server did not start listening on port \(port)") } - } catch { lastError = error isRunning = false @@ -417,7 +419,9 @@ public final class TunnelServer { /// Verifies the server is listening by attempting an HTTP health check private func isServerListening(on port: Int) async -> Bool { do { - let url = URL(string: "http://127.0.0.1:\(port)/api/health")! + guard let url = URL(string: "http://127.0.0.1:\(port)/api/health") else { + return false + } let request = URLRequest(url: url, timeoutInterval: 1.0) let (_, response) = try await URLSession.shared.data(for: request) @@ -438,7 +442,9 @@ public final class TunnelServer { throw NSError( domain: "TtyFwdError", code: 1, - userInfo: [NSLocalizedDescriptionKey: "tty-fwd binary not found. Please ensure the app was built correctly."] + userInfo: [ + NSLocalizedDescriptionKey: "tty-fwd binary not found. Please ensure the app was built correctly." + ] ) } @@ -455,40 +461,40 @@ public final class TunnelServer { return String(data: outputData, encoding: .utf8) ?? "" } else { let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - + let errorString = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Provide more descriptive error messages based on exit code let statusCode = Int(process.terminationStatus) - let errorDescription: String - - switch statusCode { + let errorDescription: String = switch statusCode { case 1: - errorDescription = "General error: \(errorString.isEmpty ? "Command failed" : errorString)" + "General error: \(errorString.isEmpty ? "Command failed" : errorString)" case 2: - errorDescription = "Misuse of shell command: \(errorString.isEmpty ? "Invalid arguments" : errorString)" + "Misuse of shell command: \(errorString.isEmpty ? "Invalid arguments" : errorString)" case 9: - errorDescription = "Process was killed (SIGKILL). The control directory may not exist or be accessible." + "Process was killed (SIGKILL). The control directory may not exist or be accessible." case -9: - errorDescription = "Process was terminated by SIGKILL. This might be due to macOS security restrictions." + "Process was terminated by SIGKILL. This might be due to macOS security restrictions." case 126: - errorDescription = "Command found but not executable" + "Command found but not executable" case 127: - errorDescription = "Command not found" + "Command not found" case 130: - errorDescription = "Process terminated by Ctrl+C" + "Process terminated by Ctrl+C" case 139: - errorDescription = "Segmentation fault" + "Segmentation fault" default: - errorDescription = errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString + errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString } - + // Log additional debugging information for SIGKILL if statusCode == 9 || statusCode == -9 { logger.error("tty-fwd executable path: \(process.executableURL?.path ?? "unknown")") logger.error("Arguments: \(args.joined(separator: " "))") - logger.error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))") + logger + .error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))") } - + throw NSError( domain: "TtyFwdError", code: statusCode, @@ -550,16 +556,18 @@ public final class TunnelServer { logger.error("Bundle resource path not found") return errorResponse(message: "Resource bundle not available", status: .internalServerError) } - + let webPublicPath = resourcePath + "/web/public" - + // Sanitize path to prevent directory traversal attacks let sanitizedPath = path.replacingOccurrences(of: "..", with: "") let fullPath = webPublicPath + "/" + sanitizedPath - + // Check if the web directory exists in Resources var isWebDirExists: ObjCBool = false - if !FileManager.default.fileExists(atPath: webPublicPath, isDirectory: &isWebDirExists) || !isWebDirExists.boolValue { + if !FileManager.default.fileExists(atPath: webPublicPath, isDirectory: &isWebDirExists) || !isWebDirExists + .boolValue + { logger.error("Web resources not found at: \(webPublicPath)") logger.error("Make sure the app was built with the 'Build Web Frontend' phase") return errorResponse(message: "Web resources not bundled", status: .internalServerError) @@ -633,7 +641,6 @@ public final class TunnelServer { private func listSessions() async -> Response { do { - let output = try await executeTtyFwd(args: ["--control-path", ttyFwdControlDir, "--list-sessions"]) let sessionsData = output.data(using: .utf8) ?? Data() @@ -665,9 +672,10 @@ public final class TunnelServer { lastModified: lastModified, pid: sessionInfo.pid ) - }.sorted { a, b in - let dateA = ISO8601DateFormatter().date(from: a.lastModified) ?? Date.distantPast - let dateB = ISO8601DateFormatter().date(from: b.lastModified) ?? Date.distantPast + } + .sorted { first, second in + let dateA = ISO8601DateFormatter().date(from: first.lastModified) ?? Date.distantPast + let dateB = ISO8601DateFormatter().date(from: second.lastModified) ?? Date.distantPast return dateA > dateB } @@ -694,7 +702,6 @@ public final class TunnelServer { return errorResponse(message: "Command array is required and cannot be empty", status: .badRequest) } - let sessionName = "session_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(9))" let cwd = resolvePath(sessionRequest.workingDir ?? "", fallback: FileManager.default.currentDirectoryPath) @@ -713,33 +720,34 @@ public final class TunnelServer { let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe - + process.currentDirectoryPath = cwd try process.run() - + // Wait for session ID from stdout (similar to Node.js implementation) var sessionId: String? let outputData = outputPipe.fileHandleForReading.availableData if !outputData.isEmpty { let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let output = output, !output.isEmpty { + if let output, !output.isEmpty { // First line of output should be the session ID (UUID) sessionId = output logger.info("Session created with ID: \(sessionId ?? "unknown")") } } - + // If we didn't get a session ID, wait a bit and try again if sessionId == nil { // Wait up to 3 seconds for session ID let maxAttempts = 30 for _ in 0.. { continuation in let task = Task { @@ -840,50 +848,53 @@ public final class TunnelServer { continuation: continuation ) } - + continuation.onTermination = { _ in task.cancel() } } - + return Response( status: .ok, headers: headers, body: ResponseBody(asyncSequence: stream) ) } - + private func streamFileContents( streamOutPath: String, continuation: AsyncStream.Continuation - ) async { + ) + async + { let startTime = Date() var headerSent = false var fileMonitor: DispatchSourceFileSystemObject? - + defer { // Ensure file monitor is cancelled when function exits fileMonitor?.cancel() } - + // Send initial connection established message var initialMessage = ByteBuffer() initialMessage.writeString(": connected\n\n") continuation.yield(initialMessage) - + // Send existing content first do { let content = try String(contentsOfFile: streamOutPath, encoding: .utf8) let lines = content.components(separatedBy: .newlines) - + for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if !trimmedLine.isEmpty { if let data = trimmedLine.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - + let parsed = try? JSONSerialization.jsonObject(with: data) + { if let dict = parsed as? [String: Any], - dict["version"] != nil && dict["width"] != nil && dict["height"] != nil { + dict["version"] != nil && dict["width"] != nil && dict["height"] != nil + { // Send header var buffer = ByteBuffer() buffer.writeString("data: \(trimmedLine)\n\n") @@ -893,7 +904,8 @@ public final class TunnelServer { // Send event with instant timestamp (0) let instantEvent = [0.0, array[1], array[2]] if let eventData = try? JSONSerialization.data(withJSONObject: instantEvent), - let eventString = String(data: eventData, encoding: .utf8) { + let eventString = String(data: eventData, encoding: .utf8) + { var buffer = ByteBuffer() buffer.writeString("data: \(eventString)\n\n") continuation.yield(buffer) @@ -905,7 +917,7 @@ public final class TunnelServer { } catch { logger.error("Error reading existing content: \(error)") } - + // Send default header if none found if !headerSent { let defaultHeader: [String: Any] = [ @@ -915,29 +927,30 @@ public final class TunnelServer { "timestamp": Int(startTime.timeIntervalSince1970), "env": ["TERM": "xterm-256color"] ] - + if let headerData = try? JSONSerialization.data(withJSONObject: defaultHeader), - let headerString = String(data: headerData, encoding: .utf8) { + let headerString = String(data: headerData, encoding: .utf8) + { var buffer = ByteBuffer() buffer.writeString("data: \(headerString)\n\n") continuation.yield(buffer) } } - + // Stream new content by monitoring file changes fileMonitor = await monitorFileChanges( streamOutPath: streamOutPath, startTime: startTime, continuation: continuation ) - + // Keep the stream open until cancelled with periodic heartbeats await withTaskCancellationHandler { // Send heartbeat every 15 seconds to keep connection alive while !Task.isCancelled { do { try await Task.sleep(nanoseconds: 15_000_000_000) // 15 seconds - + // Send SSE comment as heartbeat (comments start with ':') var heartbeat = ByteBuffer() heartbeat.writeString(": heartbeat\n\n") @@ -950,48 +963,50 @@ public final class TunnelServer { } onCancel: { [fileMonitor] in fileMonitor?.cancel() } - + continuation.finish() } - + private func monitorFileChanges( streamOutPath: String, startTime: Date, continuation: AsyncStream.Continuation - ) async -> DispatchSourceFileSystemObject? { + ) + async -> DispatchSourceFileSystemObject? + { // Open file for reading let fileDescriptor = open(streamOutPath, O_RDONLY) guard fileDescriptor >= 0 else { logger.error("Failed to open file for monitoring: \(streamOutPath)") return nil } - + // Store buffer for incomplete lines var lineBuffer = "" - + // Read entire file content from the beginning let fileSize = lseek(fileDescriptor, 0, SEEK_END) if fileSize > 0 { // Seek to beginning lseek(fileDescriptor, 0, SEEK_SET) - + // Read entire file content let buffer = UnsafeMutablePointer.allocate(capacity: Int(fileSize) + 1) defer { buffer.deallocate() } - + var totalBytesRead = 0 while totalBytesRead < fileSize { let bytesRead = read(fileDescriptor, buffer + totalBytesRead, Int(fileSize) - totalBytesRead) if bytesRead <= 0 { break } totalBytesRead += bytesRead } - + if totalBytesRead > 0 { let data = Data(bytes: buffer, count: totalBytesRead) if let initialContent = String(data: data, encoding: .utf8) { lineBuffer = initialContent let lines = lineBuffer.components(separatedBy: .newlines) - + // Process all complete lines synchronously to maintain order for i in 0.. 0 else { return } - + // Seek to last read position lseek(fileDescriptor, lastReadPosition, SEEK_SET) - + // Read new data let buffer = UnsafeMutablePointer.allocate(capacity: Int(bytesToRead) + 1) defer { buffer.deallocate() } - + let bytesRead = read(fileDescriptor, buffer, Int(bytesToRead)) guard bytesRead > 0 else { return } - + // Convert to string (handle potential UTF-8 boundary issues) let data = Data(bytes: buffer, count: bytesRead) guard let contentString = String(data: data, encoding: .utf8) else { @@ -1045,14 +1060,14 @@ public final class TunnelServer { // Store the bytes and try again with next chunk return } - + // Update last read position lastReadPosition = currentPosition - + // Process new content lineBuffer += contentString let lines = lineBuffer.components(separatedBy: .newlines) - + // Process all complete lines synchronously to maintain order if lines.count > 1 { Task { @MainActor in @@ -1069,33 +1084,36 @@ public final class TunnelServer { lineBuffer = lines.last ?? "" } } - + source.setCancelHandler { close(fileDescriptor) } - + // Start monitoring source.resume() - + return source } - + private func processNewLine( line: String, startTime: Date, continuation: AsyncStream.Continuation - ) async { + ) + async + { let trimmedLine = line.trimmingCharacters(in: .whitespaces) - + if let data = trimmedLine.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - + let parsed = try? JSONSerialization.jsonObject(with: data) + { // Skip duplicate headers if let dict = parsed as? [String: Any], - dict["version"] != nil && dict["width"] != nil && dict["height"] != nil { + dict["version"] != nil && dict["width"] != nil && dict["height"] != nil + { return } - + if let array = parsed as? [Any], array.count >= 3 { let currentTime = Date() let realTimeEvent = [ @@ -1103,9 +1121,10 @@ public final class TunnelServer { array[1], array[2] ] - + if let eventData = try? JSONSerialization.data(withJSONObject: realTimeEvent), - let eventString = String(data: eventData, encoding: .utf8) { + let eventString = String(data: eventData, encoding: .utf8) + { var buffer = ByteBuffer() buffer.writeString("data: \(eventString)\n\n") continuation.yield(buffer) @@ -1119,9 +1138,10 @@ public final class TunnelServer { "o", trimmedLine ] - + if let eventData = try? JSONSerialization.data(withJSONObject: castEvent), - let eventString = String(data: eventData, encoding: .utf8) { + let eventString = String(data: eventData, encoding: .utf8) + { var buffer = ByteBuffer() buffer.writeString("data: \(eventString)\n\n") continuation.yield(buffer) @@ -1142,7 +1162,7 @@ public final class TunnelServer { let lines = content.components(separatedBy: .newlines) .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - var header: [String: Any]? = nil + var header: [String: Any]? var events: [[Any]] = [] for line in lines { @@ -1199,7 +1219,6 @@ public final class TunnelServer { headers: [.contentType: "text/plain"], body: ResponseBody(byteBuffer: buffer) ) - } catch { logger.error("Error reading session snapshot: \(error)") return errorResponse(message: "Failed to read session snapshot") @@ -1209,11 +1228,11 @@ public final class TunnelServer { private func getSessionCast(sessionId: String) async -> Response { let streamOutPath = URL(fileURLWithPath: ttyFwdControlDir).appendingPathComponent(sessionId) .appendingPathComponent("stream-out").path - + guard FileManager.default.fileExists(atPath: streamOutPath) else { return errorResponse(message: "Session not found", status: .notFound) } - + do { // Get session info to extract command and title let sessionInfoOutput = try await executeTtyFwd(args: [ @@ -1221,17 +1240,18 @@ public final class TunnelServer { ttyFwdControlDir, "--list-sessions" ]) - + var sessionCommand: String? var sessionTitle: String? - + if let sessionData = sessionInfoOutput.data(using: .utf8), let sessions = try? JSONDecoder().decode([String: TtyFwdSession].self, from: sessionData), - let session = sessions[sessionId] { + let session = sessions[sessionId] + { sessionCommand = session.cmdline.joined(separator: " ") sessionTitle = "VibeTunnel Session: \(session.name)" } - + // Generate cast file let castGenerator = CastFileGenerator() let castData = try castGenerator.generateCastFile( @@ -1242,10 +1262,10 @@ public final class TunnelServer { title: sessionTitle, command: sessionCommand ) - + var buffer = ByteBuffer() buffer.writeBytes(castData) - + return Response( status: .ok, headers: [ @@ -1254,7 +1274,6 @@ public final class TunnelServer { ], body: ResponseBody(byteBuffer: buffer) ) - } catch { logger.error("Error generating cast file: \(error)") return errorResponse(message: "Failed to generate cast file") @@ -1288,7 +1307,8 @@ public final class TunnelServer { guard let sessionData = sessionInfoOutput.data(using: .utf8), let sessions = try? JSONDecoder().decode([String: TtyFwdSession].self, from: sessionData), - let session = sessions[sessionId] else { + let session = sessions[sessionId] + else { logger.error("Session \(sessionId) not found in active sessions") return errorResponse(message: "Session not found", status: .notFound) } @@ -1304,7 +1324,7 @@ public final class TunnelServer { let processExists = kill(pid_t(session.pid), 0) == 0 if !processExists { logger.error("Session \(sessionId) process \(session.pid) is dead, cleaning up") - + // Try to cleanup the stale session do { _ = try await executeTtyFwd(args: [ @@ -1317,16 +1337,25 @@ public final class TunnelServer { } catch { logger.error("Failed to cleanup stale session: \(error)") } - + return errorResponse(message: "Session process has died", status: HTTPResponse.Status(code: 410)) } } - let specialKeys = ["arrow_up", "arrow_down", "arrow_left", "arrow_right", "escape", "enter", "ctrl_enter", "shift_enter"] + let specialKeys = [ + "arrow_up", + "arrow_down", + "arrow_left", + "arrow_right", + "escape", + "enter", + "ctrl_enter", + "shift_enter" + ] let isSpecialKey = specialKeys.contains(text) let startTime = Date() - + if isSpecialKey { _ = try await executeTtyFwd(args: [ "--control-path", @@ -1336,7 +1365,7 @@ public final class TunnelServer { "--send-key", text ]) - let elapsed = Date().timeIntervalSince(startTime) * 1000 + let elapsed = Date().timeIntervalSince(startTime) * 1_000 logger.info("Successfully sent key: \(text) (\(Int(elapsed))ms)") } else { _ = try await executeTtyFwd(args: [ @@ -1347,17 +1376,16 @@ public final class TunnelServer { "--send-text", text ]) - let elapsed = Date().timeIntervalSince(startTime) * 1000 + let elapsed = Date().timeIntervalSince(startTime) * 1_000 logger.info("Successfully sent text: \(text) (\(Int(elapsed))ms)") } struct SuccessResponse: Codable { let success: Bool } - + let response = SuccessResponse(success: true) return jsonResponse(response) - } catch let decodingError as DecodingError { logger.error("Error decoding input request: \(decodingError)") return errorResponse(message: "Invalid request format", status: .badRequest) @@ -1374,7 +1402,6 @@ public final class TunnelServer { let response = SimpleResponse(success: true, message: "All exited sessions cleaned up") return jsonResponse(response) - } catch { logger.error("Error cleaning up exited sessions: \(error)") return errorResponse(message: "Failed to cleanup exited sessions") @@ -1417,15 +1444,15 @@ public final class TunnelServer { size: size, isDir: isDir ) - }.sorted { a, b in - if a.isDir && !b.isDir { return true } - if !a.isDir && b.isDir { return false } - return a.name.localizedCompare(b.name) == .orderedAscending + } + .sorted { first, second in + if first.isDir && !second.isDir { return true } + if !first.isDir && second.isDir { return false } + return first.name.localizedCompare(second.name) == .orderedAscending } let listing = DirectoryListing(absolutePath: expandedPath, files: files) return jsonResponse(listing) - } catch { logger.error("Error listing directory: \(error)") return errorResponse(message: "Failed to list directory") diff --git a/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift b/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift index c51f0714..b28431f9 100644 --- a/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift +++ b/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift @@ -4,30 +4,36 @@ import AppKit enum WindowCenteringHelper { /// Centers a window on the active screen (where the mouse cursor is located) /// - Parameter window: The NSWindow to center - @MainActor static func centerOnActiveScreen(_ window: NSWindow) { + @MainActor + static func centerOnActiveScreen(_ window: NSWindow) { if let screen = NSScreen.main ?? NSScreen.screens.first { let screenFrame = screen.visibleFrame let windowFrame = window.frame - + let newX = screenFrame.midX - windowFrame.width / 2 let newY = screenFrame.midY - windowFrame.height / 2 - + window.setFrameOrigin(NSPoint(x: newX, y: newY)) } } - + /// Positions a window off-screen (useful for hidden windows) /// - Parameter window: The NSWindow to position off-screen - @MainActor static func positionOffScreen(_ window: NSWindow) { + @MainActor + static func positionOffScreen(_ window: NSWindow) { if let screen = NSScreen.main { let screenFrame = screen.frame - window.setFrame(NSRect(x: screenFrame.midX, y: screenFrame.minY - 1000, width: 1, height: 1), display: false) + window.setFrame( + NSRect(x: screenFrame.midX, y: screenFrame.minY - 1_000, width: 1, height: 1), + display: false + ) } } - + /// Centers a window using the built-in NSWindow center method /// - Parameter window: The NSWindow to center - @MainActor static func centerDefault(_ window: NSWindow) { + @MainActor + static func centerDefault(_ window: NSWindow) { window.center() } -} \ No newline at end of file +} diff --git a/VibeTunnel/Presentation/Views/MenuBarView.swift b/VibeTunnel/Presentation/Views/MenuBarView.swift index 80cef8e5..1fecd11a 100644 --- a/VibeTunnel/Presentation/Views/MenuBarView.swift +++ b/VibeTunnel/Presentation/Views/MenuBarView.swift @@ -2,9 +2,12 @@ import SwiftUI /// Main menu bar view displaying session status and app controls struct MenuBarView: View { - @Environment(SessionMonitor.self) var sessionMonitor - @Environment(ServerMonitor.self) var serverMonitor - @AppStorage("showInDock") private var showInDock = false + @Environment(SessionMonitor.self) + var sessionMonitor + @Environment(ServerMonitor.self) + var serverMonitor + @AppStorage("showInDock") + private var showInDock = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -15,11 +18,12 @@ struct MenuBarView: View { // Open Dashboard button Button(action: { - let dashboardURL = URL(string: "http://127.0.0.1:\(serverMonitor.port)")! - NSWorkspace.shared.open(dashboardURL) - }) { + if let dashboardURL = URL(string: "http://127.0.0.1:\(serverMonitor.port)") { + NSWorkspace.shared.open(dashboardURL) + } + }, label: { Label("Open Dashboard", systemImage: "safari") - } + }) .buttonStyle(MenuButtonStyle()) .disabled(!serverMonitor.isRunning) @@ -31,14 +35,6 @@ struct MenuBarView: View { .padding(.horizontal, 12) .padding(.vertical, 8) - // Session list - if sessionMonitor.sessionCount > 0 { - SessionListView(sessions: sessionMonitor.sessions) - .padding(.horizontal, 12) - .padding(.bottom, 4) - .frame(minWidth: 280) - } - Divider() .padding(.vertical, 4) @@ -48,34 +44,38 @@ struct MenuBarView: View { // Show Tutorial Button(action: { AppDelegate.showWelcomeScreen() - }) { + }, label: { Label("Show Tutorial", systemImage: "book") - } - + }) + + Divider() + // Website Button(action: { if let url = URL(string: "http://vibetunnel.sh") { NSWorkspace.shared.open(url) } - }) { + }, label: { Label("Website", systemImage: "globe") - } + }) // Report Issue Button(action: { if let url = URL(string: "https://github.com/amantus-ai/vibetunnel/issues") { NSWorkspace.shared.open(url) } - }) { + }, label: { Label("Report Issue", systemImage: "exclamationmark.triangle") - } + }) + + Divider() // Check for Updates Button(action: { SparkleUpdaterManager.shared.checkForUpdates() - }) { + }, label: { Label("Check for Updates…", systemImage: "arrow.down.circle") - } + }) // Version (non-interactive) Text("Version \(appVersion)") @@ -124,9 +124,9 @@ struct MenuBarView: View { // Quit button Button(action: { NSApplication.shared.terminate(nil) - }) { + }, label: { Label("Quit", systemImage: "power") - } + }) .buttonStyle(MenuButtonStyle()) .keyboardShortcut("q", modifiers: .command) } @@ -265,4 +265,3 @@ struct MenuButtonStyle: ButtonStyle { } } } - diff --git a/VibeTunnel/Presentation/Views/ServerConsoleView.swift b/VibeTunnel/Presentation/Views/ServerConsoleView.swift index 89e5ce18..b8c704a1 100644 --- a/VibeTunnel/Presentation/Views/ServerConsoleView.swift +++ b/VibeTunnel/Presentation/Views/ServerConsoleView.swift @@ -1,12 +1,5 @@ -// -// ServerConsoleView.swift -// VibeTunnel -// -// Console view for displaying server logs -// - -import SwiftUI import Observation +import SwiftUI /// View for displaying server console logs struct ServerConsoleView: View { @@ -14,7 +7,7 @@ struct ServerConsoleView: View { @State private var autoScroll = true @State private var filterText = "" @State private var selectedLevel: ServerLogEntry.Level? - + var body: some View { VStack(spacing: 0) { // Header with controls @@ -23,11 +16,11 @@ struct ServerConsoleView: View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) - + TextField("Filter logs...", text: $filterText) .textFieldStyle(.roundedBorder) .frame(width: 200) - + Picker("Level", selection: $selectedLevel) { Text("All").tag(nil as ServerLogEntry.Level?) Text("Debug").tag(ServerLogEntry.Level.debug) @@ -38,20 +31,20 @@ struct ServerConsoleView: View { .pickerStyle(.menu) .labelsHidden() } - + Spacer() - + // Controls HStack(spacing: 12) { Toggle("Auto-scroll", isOn: $autoScroll) .toggleStyle(.checkbox) - - Button(action: { viewModel.clearLogs() }) { + + Button(action: viewModel.clearLogs) { Label("Clear", systemImage: "trash") } .buttonStyle(.borderless) - - Button(action: { viewModel.exportLogs() }) { + + Button(action: viewModel.exportLogs) { Label("Export", systemImage: "square.and.arrow.up") } .buttonStyle(.borderless) @@ -59,9 +52,9 @@ struct ServerConsoleView: View { } .padding() .background(Color(NSColor.controlBackgroundColor)) - + Divider() - + // Console output ScrollViewReader { proxy in ScrollView { @@ -70,7 +63,7 @@ struct ServerConsoleView: View { ServerLogEntryView(entry: entry) .id(entry.id) } - + // Invisible anchor for auto-scrolling Color.clear .frame(height: 1) @@ -94,19 +87,19 @@ struct ServerConsoleView: View { viewModel.cleanup() } } - + private var filteredLogs: [ServerLogEntry] { viewModel.logs.filter { entry in // Level filter - if let selectedLevel = selectedLevel, entry.level != selectedLevel { + if let selectedLevel, entry.level != selectedLevel { return false } - + // Text filter if !filterText.isEmpty { return entry.message.localizedCaseInsensitiveContains(filterText) } - + return true } } @@ -115,7 +108,7 @@ struct ServerConsoleView: View { /// View for a single log entry struct ServerLogEntryView: View { let entry: ServerLogEntry - + var body: some View { HStack(alignment: .top, spacing: 8) { // Timestamp @@ -123,13 +116,13 @@ struct ServerLogEntryView: View { .font(.caption) .foregroundStyle(.secondary) .frame(width: 80, alignment: .leading) - + // Level indicator Circle() .fill(entry.level.color) .frame(width: 6, height: 6) .padding(.top, 6) - + // Source badge Text(entry.source.displayName) .font(.caption2) @@ -138,7 +131,7 @@ struct ServerLogEntryView: View { .background(entry.source.color.opacity(0.2)) .foregroundStyle(entry.source.color) .clipShape(Capsule()) - + // Message Text(entry.message) .textSelection(.enabled) @@ -154,10 +147,10 @@ struct ServerLogEntryView: View { @Observable class ServerConsoleViewModel { private(set) var logs: [ServerLogEntry] = [] - + private var logTask: Task? - private let maxLogs = 1000 - + private let maxLogs = 1_000 + init() { // Subscribe to server logs using async stream logTask = Task { [weak self] in @@ -166,39 +159,40 @@ class ServerConsoleViewModel { } } } - + func cleanup() { logTask?.cancel() } - + private func addLog(_ entry: ServerLogEntry) { logs.append(entry) - + // Trim old logs if needed if logs.count > maxLogs { logs.removeFirst(logs.count - maxLogs) } } - + func clearLogs() { logs.removeAll() } - + func exportLogs() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - + let logText = logs.map { entry in let timestamp = dateFormatter.string(from: entry.timestamp) let level = String(describing: entry.level).uppercased().padding(toLength: 7, withPad: " ", startingAt: 0) let source = entry.source.displayName.padding(toLength: 12, withPad: " ", startingAt: 0) return "[\(timestamp)] [\(level)] [\(source)] \(entry.message)" - }.joined(separator: "\n") - + } + .joined(separator: "\n") + let savePanel = NSSavePanel() savePanel.allowedContentTypes = [.plainText] savePanel.nameFieldStringValue = "vibetunnel-server-logs.txt" - + if savePanel.runModal() == .OK, let url = savePanel.url { try? logText.write(to: url, atomically: true, encoding: .utf8) } @@ -216,19 +210,19 @@ extension ServerLogEntry: Identifiable { extension ServerLogEntry.Level { var color: Color { switch self { - case .debug: return .gray - case .info: return .blue - case .warning: return .orange - case .error: return .red + case .debug: .gray + case .info: .blue + case .warning: .orange + case .error: .red } } - + var textColor: Color { switch self { - case .debug: return .secondary - case .info: return .primary - case .warning: return .orange - case .error: return .red + case .debug: .secondary + case .info: .primary + case .warning: .orange + case .error: .red } } } @@ -236,8 +230,8 @@ extension ServerLogEntry.Level { extension ServerMode { var color: Color { switch self { - case .hummingbird: return .blue - case .rust: return .orange + case .hummingbird: .blue + case .rust: .orange } } -} \ No newline at end of file +} diff --git a/VibeTunnel/Presentation/Views/SettingsView.swift b/VibeTunnel/Presentation/Views/SettingsView.swift index b7be62c4..77b666fa 100644 --- a/VibeTunnel/Presentation/Views/SettingsView.swift +++ b/VibeTunnel/Presentation/Views/SettingsView.swift @@ -1,5 +1,6 @@ -import SwiftUI import AppKit +import os.log +import SwiftUI /// Represents the available tabs in the Settings window enum SettingsTab: String, CaseIterable { @@ -38,7 +39,8 @@ extension Notification.Name { struct SettingsView: View { @State private var selectedTab: SettingsTab = .general @State private var contentSize: CGSize = .zero - @AppStorage("debugMode") private var debugMode = false + @AppStorage("debugMode") + private var debugMode = false /// Define ideal sizes for each tab private let tabSizes: [SettingsTab: CGSize] = [ @@ -271,13 +273,13 @@ struct DashboardSettingsView: View { private var ngrokTokenPresent = false @AppStorage("dashboardAccessMode") private var accessModeString = DashboardAccessMode.localhost.rawValue - + @State private var password = "" @State private var confirmPassword = "" @State private var showPasswordFields = false @State private var passwordError: String? @State private var passwordSaved = false - + @State private var ngrokAuthToken = "" @State private var ngrokStatus: NgrokTunnelStatus? @State private var isStartingNgrok = false @@ -288,14 +290,15 @@ struct DashboardSettingsView: View { @State private var serverErrorMessage = "" @State private var isTokenRevealed = false @State private var maskedToken = "" - + private let dashboardKeychain = DashboardKeychain.shared private let ngrokService = NgrokService.shared - + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "DashboardSettings") + private var accessMode: DashboardAccessMode { DashboardAccessMode(rawValue: accessModeString) ?? .localhost } - + var body: some View { NavigationStack { Form { @@ -313,24 +316,24 @@ struct DashboardSettingsView: View { passwordSaved = false } } - + Text("Require a password to access the dashboard from remote connections.") .font(.caption) .foregroundStyle(.secondary) - + if showPasswordFields || (passwordEnabled && !passwordSaved) { VStack(spacing: 8) { SecureField("Password", text: $password) .textFieldStyle(.roundedBorder) SecureField("Confirm Password", text: $confirmPassword) .textFieldStyle(.roundedBorder) - + if let error = passwordError { Text(error) .font(.caption) .foregroundColor(.red) } - + HStack { Button("Cancel") { showPasswordFields = false @@ -340,7 +343,7 @@ struct DashboardSettingsView: View { passwordError = nil } .buttonStyle(.bordered) - + Button("Save Password") { savePassword() } @@ -350,7 +353,7 @@ struct DashboardSettingsView: View { } .padding(.top, 4) } - + if passwordSaved { HStack { Image(systemName: "checkmark.circle.fill") @@ -373,10 +376,12 @@ struct DashboardSettingsView: View { Text("Security") .font(.headline) } footer: { - Text("When password protection is enabled, localhost connections can still access without a password. For remote access, any username is accepted - only the password is verified.") - .font(.caption) + Text( + "When password protection is enabled, localhost connections can still access without a password. For remote access, any username is accepted - only the password is verified." + ) + .font(.caption) } - + Section { // Access Mode VStack(alignment: .leading, spacing: 8) { @@ -401,10 +406,10 @@ struct DashboardSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - + Divider() .padding(.vertical, 4) - + // Port Configuration VStack(alignment: .leading, spacing: 4) { HStack { @@ -414,7 +419,7 @@ struct DashboardSettingsView: View { .textFieldStyle(.roundedBorder) .frame(width: 80) .multilineTextAlignment(.center) - .onChange(of: serverPort) { oldValue, newValue in + .onChange(of: serverPort) { _, newValue in // Validate port number if let port = Int(newValue), port > 0, port < 65_536 { restartServerWithNewPort(port) @@ -429,14 +434,14 @@ struct DashboardSettingsView: View { Text("Server Configuration") .font(.headline) } - + Section { VStack(alignment: .leading, spacing: 12) { // ngrok Enable Toggle VStack(alignment: .leading, spacing: 4) { Toggle("Enable ngrok tunnel", isOn: $ngrokEnabled) .onChange(of: ngrokEnabled) { oldValue, newValue in - print("ngrok toggle changed from \(oldValue) to \(newValue)") + logger.debug("ngrok toggle changed from \(oldValue) to \(newValue)") if newValue { // Add a small delay to ensure auth token is saved to keychain Task { @@ -488,9 +493,9 @@ struct DashboardSettingsView: View { } Button(action: { toggleTokenVisibility() - }) { + }, label: { Image(systemName: isTokenRevealed ? "eye.slash" : "eye") - } + }) .buttonStyle(.plain) .help(isTokenRevealed ? "Hide token" : "Reveal token") } @@ -500,8 +505,9 @@ struct DashboardSettingsView: View { .font(.caption) .foregroundStyle(.secondary) Button("ngrok.com") { - NSWorkspace.shared - .open(URL(string: "https://dashboard.ngrok.com/auth/your-authtoken")!) + if let url = URL(string: "https://dashboard.ngrok.com/auth/your-authtoken") { + NSWorkspace.shared.open(url) + } } .buttonStyle(.link) .font(.caption) @@ -526,7 +532,7 @@ struct DashboardSettingsView: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(publicUrl, forType: .string) } - + Button("Open Browser") { if let url = URL(string: publicUrl) { NSWorkspace.shared.open(url) @@ -590,58 +596,60 @@ struct DashboardSettingsView: View { passwordSaved = true passwordEnabled = true } - + // Check if token exists without triggering keychain if ngrokService.hasAuthToken && !ngrokTokenPresent { ngrokTokenPresent = true } - + // Update masked field based on token presence if ngrokTokenPresent && !isTokenRevealed { maskedToken = String(repeating: "•", count: 12) } } .alert("ngrok Auth Token Required", isPresented: $showingAuthTokenAlert) { - Button("OK") { } + Button("OK") {} } message: { - Text("Please enter your ngrok auth token before enabling the tunnel. You can get a free auth token at ngrok.com") + Text( + "Please enter your ngrok auth token before enabling the tunnel. You can get a free auth token at ngrok.com" + ) } .alert("Keychain Access Error", isPresented: $showingKeychainAlert) { - Button("OK") { } + Button("OK") {} } message: { Text("Failed to save the auth token to the keychain. Please check your keychain permissions and try again.") } .alert("Failed to Restart Server", isPresented: $showingServerErrorAlert) { - Button("OK") { } + Button("OK") {} } message: { Text(serverErrorMessage) } } - + private func savePassword() { passwordError = nil - + guard !password.isEmpty else { passwordError = "Password cannot be empty" return } - + guard password == confirmPassword else { passwordError = "Passwords do not match" return } - + guard password.count >= 6 else { passwordError = "Password must be at least 6 characters" return } - + if dashboardKeychain.setPassword(password) { passwordSaved = true showPasswordFields = false password = "" confirmPassword = "" - + // When password is set for the first time, automatically switch to network mode if accessMode == .localhost { accessModeString = DashboardAccessMode.network.rawValue @@ -651,53 +659,53 @@ struct DashboardSettingsView: View { passwordError = "Failed to save password to keychain" } } - + private func restartServerWithNewPort(_ port: Int) { Task { // Update the port in ServerManager and restart ServerManager.shared.port = String(port) await ServerManager.shared.restart() - print("Server restarted on port \(port)") + logger.info("Server restarted on port \(port)") // Restart session monitoring with new port SessionMonitor.shared.stopMonitoring() SessionMonitor.shared.startMonitoring() } } - + private func restartServerWithNewBindAddress() { Task { // Update the bind address in ServerManager and restart ServerManager.shared.bindAddress = accessMode.bindAddress await ServerManager.shared.restart() - print("Server restarted with bind address \(accessMode.bindAddress)") + logger.info("Server restarted with bind address \(accessMode.bindAddress)") // Restart session monitoring SessionMonitor.shared.stopMonitoring() SessionMonitor.shared.startMonitoring() } } - + private func checkAndStartNgrok() { - print("checkAndStartNgrok called") - + logger.debug("checkAndStartNgrok called") + // Check if we have a token in the keychain without accessing it guard ngrokTokenPresent || ngrokService.hasAuthToken else { - print("No auth token stored") + logger.debug("No auth token stored") ngrokError = "Please enter your ngrok auth token first" ngrokEnabled = false showingAuthTokenAlert = true return } - + // If token hasn't been revealed yet, we need to access it from keychain if !isTokenRevealed && ngrokAuthToken.isEmpty { // This will trigger keychain access if let token = ngrokService.authToken { ngrokAuthToken = token - print("Retrieved token from keychain for ngrok start") + logger.debug("Retrieved token from keychain for ngrok start") } else { - print("Failed to retrieve token from keychain") + logger.error("Failed to retrieve token from keychain") ngrokError = "Failed to access auth token. Please try again." ngrokEnabled = false showingKeychainAlert = true @@ -705,20 +713,20 @@ struct DashboardSettingsView: View { } } - print("Starting ngrok with auth token present") + logger.debug("Starting ngrok with auth token present") isStartingNgrok = true ngrokError = nil Task { do { let port = Int(serverPort) ?? 4_020 - print("Starting ngrok on port \(port)") + logger.info("Starting ngrok on port \(port)") _ = try await ngrokService.start(port: port) isStartingNgrok = false ngrokStatus = await ngrokService.getStatus() - print("ngrok started successfully") + logger.info("ngrok started successfully") } catch { - print("ngrok start error: \(error)") + logger.error("ngrok start error: \(error)") isStartingNgrok = false ngrokError = error.localizedDescription ngrokEnabled = false @@ -733,7 +741,7 @@ struct DashboardSettingsView: View { // Don't clear the error here - let it remain visible } } - + private func toggleTokenVisibility() { if isTokenRevealed { // Hide the token @@ -780,14 +788,14 @@ struct AdvancedSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - + VStack(alignment: .leading, spacing: 4) { Toggle("Clean up old sessions on startup", isOn: $cleanupOnStartup) Text("Automatically remove terminated sessions when the app starts.") .font(.caption) .foregroundStyle(.secondary) } - + VStack(alignment: .leading, spacing: 4) { Toggle("Debug mode", isOn: $debugMode) Text("Enable additional logging and debugging features.") @@ -804,7 +812,7 @@ struct AdvancedSettingsView: View { .navigationTitle("Advanced Settings") } } - + private func installCLITool() { let installer = CLIInstaller() installer.installCLITool() @@ -823,14 +831,19 @@ struct DebugSettingsView: View { @State private var lastError: String? @State private var testResult: String? @State private var isTesting = false - @AppStorage("debugMode") private var debugMode = false - @AppStorage("logLevel") private var logLevel = "info" - @AppStorage("serverMode") private var serverModeString = ServerMode.rust.rawValue + @AppStorage("debugMode") + private var debugMode = false + @AppStorage("logLevel") + private var logLevel = "info" + @AppStorage("serverMode") + private var serverModeString = ServerMode.rust.rawValue @State private var serverManager = ServerManager.shared @State private var isServerHealthy = false @State private var heartbeatTask: Task? @State private var showPurgeConfirmation = false + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "DebugSettings") + private var isServerRunning: Bool { serverMonitor.isRunning } @@ -857,10 +870,11 @@ struct DebugSettingsView: View { .scaleEffect(0.6) } } - Text(isServerHealthy ? "Server is running on port \(serverPort)" : - isServerRunning ? "Server starting... (checking health)" : "Server is stopped") - .font(.caption) - .foregroundStyle(.secondary) + Text(isServerHealthy ? "Server is running on port \(serverPort)" : + isServerRunning ? "Server starting... (checking health)" : "Server is stopped" + ) + .font(.caption) + .foregroundStyle(.secondary) } Spacer() @@ -922,7 +936,7 @@ struct DebugSettingsView: View { .labelsHidden() .disabled(serverManager.isSwitching) } - + if serverManager.isSwitching { HStack { ProgressView() @@ -942,18 +956,21 @@ struct DebugSettingsView: View { .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } - + Section { // Server Information VStack(alignment: .leading, spacing: 8) { LabeledContent("Status") { HStack { - Image(systemName: isServerHealthy ? "checkmark.circle.fill" : - isServerRunning ? "exclamationmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(isServerHealthy ? .green : - isServerRunning ? .orange : .secondary) - Text(isServerHealthy ? "Healthy" : - isServerRunning ? "Unhealthy" : "Stopped") + Image(systemName: isServerHealthy ? "checkmark.circle.fill" : + isServerRunning ? "exclamationmark.circle.fill" : "xmark.circle.fill" + ) + .foregroundStyle(isServerHealthy ? .green : + isServerRunning ? .orange : .secondary + ) + Text(isServerHealthy ? "Healthy" : + isServerRunning ? "Unhealthy" : "Stopped" + ) } } @@ -965,7 +982,7 @@ struct DebugSettingsView: View { Text("http://127.0.0.1:\(serverPort)") .font(.system(.body, design: .monospaced)) } - + LabeledContent("Mode") { Text(serverManager.currentServer?.serverType.displayName ?? "None") .foregroundStyle(.secondary) @@ -1070,7 +1087,7 @@ struct DebugSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - + VStack(alignment: .leading, spacing: 8) { HStack { Text("System Logs") @@ -1098,7 +1115,7 @@ struct DebugSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - + VStack(alignment: .leading, spacing: 8) { HStack { Text("Welcome Screen") @@ -1112,7 +1129,7 @@ struct DebugSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - + VStack(alignment: .leading, spacing: 8) { HStack { Text("User Defaults") @@ -1155,12 +1172,14 @@ struct DebugSettingsView: View { isServerHealthy = false } .alert("Purge All User Defaults?", isPresented: $showPurgeConfirmation) { - Button("Cancel", role: .cancel) { } + Button("Cancel", role: .cancel) {} Button("Purge", role: .destructive) { purgeAllUserDefaults() } } message: { - Text("This will remove all stored preferences and reset the app to its default state. The app will quit after purging.") + Text( + "This will remove all stored preferences and reset the app to its default state. The app will quit after purging." + ) } } } @@ -1193,7 +1212,10 @@ struct DebugSettingsView: View { Task { do { - let url = URL(string: "http://127.0.0.1:\(serverPort)\(endpoint.path)")! + guard let url = URL(string: "http://127.0.0.1:\(serverPort)\(endpoint.path)") else { + testResult = "Invalid URL" + return + } var request = URLRequest(url: url) request.httpMethod = endpoint.method @@ -1228,7 +1250,7 @@ struct DebugSettingsView: View { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: appDirectory.path) } } - + private func showServerConsole() { // Create a new window for the server console let consoleWindow = NSWindow( @@ -1239,66 +1261,68 @@ struct DebugSettingsView: View { ) consoleWindow.title = "Server Console" consoleWindow.center() - + let consoleView = ServerConsoleView() .onDisappear { // This will be called when the window closes } consoleWindow.contentView = NSHostingView(rootView: consoleView) - + let windowController = NSWindowController(window: consoleWindow) windowController.showWindow(nil) } - + private func startHeartbeatMonitoring() { // Cancel any existing heartbeat task heartbeatTask?.cancel() - + // Start a new heartbeat monitoring task heartbeatTask = Task { while !Task.isCancelled { // Check server health let healthy = await checkServerHealth() - + // Update UI on main actor await MainActor.run { isServerHealthy = healthy } - + // Wait before next heartbeat try? await Task.sleep(for: .seconds(2)) } } } - + private func checkServerHealth() async -> Bool { guard isServerRunning else { return false } - + do { - let url = URL(string: "http://127.0.0.1:\(serverPort)/api/health")! + guard let url = URL(string: "http://127.0.0.1:\(serverPort)/api/health") else { + return false + } var request = URLRequest(url: url) request.timeoutInterval = 1.0 // Quick timeout for heartbeat - + let (_, response) = try await URLSession.shared.data(for: request) - + if let httpResponse = response as? HTTPURLResponse { return httpResponse.statusCode == 200 } } catch { // Server not responding or error - print("Server health check failed: \(error.localizedDescription)") + logger.error("Server health check failed: \(error.localizedDescription)") } - + return false } - + private func purgeAllUserDefaults() { // Get the app's bundle identifier if let bundleIdentifier = Bundle.main.bundleIdentifier { // Remove all UserDefaults for this app UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) UserDefaults.standard.synchronize() - + // Quit the app after a short delay to ensure the purge completes DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApplication.shared.terminate(nil) diff --git a/VibeTunnel/Presentation/Views/WelcomeView.swift b/VibeTunnel/Presentation/Views/WelcomeView.swift index aa15f25d..908b29c3 100644 --- a/VibeTunnel/Presentation/Views/WelcomeView.swift +++ b/VibeTunnel/Presentation/Views/WelcomeView.swift @@ -2,10 +2,12 @@ import SwiftUI struct WelcomeView: View { @State private var currentPage = 0 - @Environment(\.dismiss) private var dismiss - @AppStorage("hasSeenWelcome") private var hasSeenWelcome = false + @Environment(\.dismiss) + private var dismiss + @AppStorage("hasSeenWelcome") + private var hasSeenWelcome = false @State private var cliInstaller = CLIInstaller() - + var body: some View { VStack(spacing: 0) { // Custom page view implementation for macOS @@ -15,19 +17,19 @@ struct WelcomeView: View { WelcomePageView() .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } - + // Page 2: VT Command if currentPage == 1 { VTCommandPageView(cliInstaller: cliInstaller) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } - + // Page 3: Protect Your Dashboard if currentPage == 2 { ProtectDashboardPageView() .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } - + // Page 4: Accessing Dashboard if currentPage == 3 { AccessDashboardPageView() @@ -35,7 +37,7 @@ struct WelcomeView: View { } } .animation(.easeInOut, value: currentPage) - + // Custom page indicators and navigation VStack(spacing: 16) { // Page indicators @@ -48,11 +50,11 @@ struct WelcomeView: View { } } .padding(.top, 12) - + // Navigation button HStack { Spacer() - + Button(action: handleNextAction) { Text(buttonTitle) .frame(minWidth: 80) @@ -71,11 +73,11 @@ struct WelcomeView: View { currentPage = 0 } } - + private var buttonTitle: String { currentPage == 3 ? "Finish" : "Next" } - + private func handleNextAction() { if currentPage < 3 { withAnimation { @@ -91,36 +93,39 @@ struct WelcomeView: View { } // MARK: - Welcome Page + struct WelcomePageView: View { var body: some View { VStack(spacing: 40) { Spacer() - + // App icon Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 156, height: 156) .shadow(radius: 10) - + VStack(spacing: 20) { Text("Welcome to VibeTunnel") .font(.largeTitle) .fontWeight(.semibold) - + Text("Remote control terminals from any device through a secure tunnel.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 480) - - Text("You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 480) - .fixedSize(horizontal: false, vertical: true) + + Text( + "You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) } - + Spacer() } .padding() @@ -128,36 +133,39 @@ struct WelcomePageView: View { } // MARK: - VT Command Page + struct VTCommandPageView: View { var cliInstaller: CLIInstaller - + var body: some View { VStack(spacing: 30) { Spacer() - + // App icon Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 156, height: 156) .shadow(radius: 10) - + VStack(spacing: 16) { Text("Capturing Terminal Apps") .font(.largeTitle) .fontWeight(.semibold) - - Text("VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 480) - .fixedSize(horizontal: false, vertical: true) - + + Text( + "VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) + Text("For example, to remote control Claude Code, type:") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - + Text("vt claude") .font(.system(.body, design: .monospaced)) .foregroundColor(.primary) @@ -165,7 +173,7 @@ struct VTCommandPageView: View { .padding(.vertical, 8) .background(Color.gray.opacity(0.1)) .cornerRadius(6) - + // Install VT Binary button VStack(spacing: 12) { if cliInstaller.isInstalled { @@ -183,13 +191,13 @@ struct VTCommandPageView: View { } .buttonStyle(.borderedProminent) .disabled(cliInstaller.isInstalling) - + if cliInstaller.isInstalling { ProgressView() .scaleEffect(0.8) } } - + if let error = cliInstaller.lastError { Text(error) .font(.caption) @@ -198,7 +206,7 @@ struct VTCommandPageView: View { } } } - + Spacer() } .padding() @@ -209,53 +217,56 @@ struct VTCommandPageView: View { } // MARK: - Protect Dashboard Page + struct ProtectDashboardPageView: View { @State private var password = "" @State private var confirmPassword = "" @State private var showError = false @State private var errorMessage = "" @State private var isPasswordSet = false - + private let dashboardKeychain = DashboardKeychain.shared - + var body: some View { VStack(spacing: 30) { Spacer() - + // App icon Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 156, height: 156) .shadow(radius: 10) - + VStack(spacing: 16) { Text("Protect Your Dashboard") .font(.largeTitle) .fontWeight(.semibold) - - Text("If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 480) - .fixedSize(horizontal: false, vertical: true) - + + Text( + "If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) + // Password fields VStack(spacing: 12) { SecureField("Password", text: $password) .textFieldStyle(.roundedBorder) .frame(maxWidth: 300) - + SecureField("Confirm Password", text: $confirmPassword) .textFieldStyle(.roundedBorder) .frame(maxWidth: 300) - + if showError { Text(errorMessage) .font(.caption) .foregroundColor(.red) } - + if isPasswordSet { HStack { Image(systemName: "checkmark.circle.fill") @@ -265,49 +276,51 @@ struct ProtectDashboardPageView: View { } .font(.caption) } - + Button("Set Password") { setPassword() } .buttonStyle(.bordered) .disabled(password.isEmpty || isPasswordSet) - + Text("Leave empty to skip password protection") .font(.caption) .foregroundColor(.secondary) } } - + Spacer() } .padding() } - + private func setPassword() { showError = false - + guard !password.isEmpty else { return } - + guard password == confirmPassword else { errorMessage = "Passwords do not match" showError = true return } - + guard password.count >= 6 else { errorMessage = "Password must be at least 6 characters" showError = true return } - + if dashboardKeychain.setPassword(password) { isPasswordSet = true UserDefaults.standard.set(true, forKey: "dashboardPasswordEnabled") - + // When password is set for the first time, automatically switch to network mode - let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "") ?? .localhost + let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard + .string(forKey: "dashboardAccessMode") ?? "" + ) ?? .localhost if currentMode == .localhost { UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode") } @@ -319,70 +332,76 @@ struct ProtectDashboardPageView: View { } // MARK: - Access Dashboard Page + struct AccessDashboardPageView: View { - @AppStorage("ngrokEnabled") private var ngrokEnabled = false - @AppStorage("serverPort") private var serverPort = "4020" - + @AppStorage("ngrokEnabled") + private var ngrokEnabled = false + @AppStorage("serverPort") + private var serverPort = "4020" + var body: some View { VStack(spacing: 30) { Spacer() - + // App icon Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 156, height: 156) .shadow(radius: 10) - + VStack(spacing: 16) { Text("Accessing Your Dashboard") .font(.largeTitle) .fontWeight(.semibold) - - Text("To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended).") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 480) - .fixedSize(horizontal: false, vertical: true) - + + Text( + "To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended)." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 12) { // Open Dashboard button Button(action: { - let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)")! - NSWorkspace.shared.open(dashboardURL) - }) { + if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") { + NSWorkspace.shared.open(dashboardURL) + } + }, label: { HStack { Image(systemName: "safari") Text("Open Dashboard") } - } + }) .buttonStyle(.borderedProminent) .controlSize(.large) - + // Tailscale link button TailscaleLink() } } - + // Credits VStack(spacing: 4) { Text("VibeTunnel is open source and brought to you by") .font(.caption) .foregroundColor(.secondary) - + HStack(spacing: 4) { CreditLink(name: "@badlogic", url: "https://mariozechner.at/") - + Text("•") .font(.caption) .foregroundColor(.secondary) - + CreditLink(name: "@mitsuhiko", url: "https://lucumr.pocoo.org/") - + Text("•") .font(.caption) .foregroundColor(.secondary) - + CreditLink(name: "@steipete", url: "https://steipete.me") } } @@ -393,19 +412,22 @@ struct AccessDashboardPageView: View { } // MARK: - Tailscale Link Component + struct TailscaleLink: View { @State private var isHovering = false - + var body: some View { Button(action: { - NSWorkspace.shared.open(URL(string: "https://tailscale.com/")!) - }) { + if let tailscaleURL = URL(string: "https://tailscale.com/") { + NSWorkspace.shared.open(tailscaleURL) + } + }, label: { HStack { Image(systemName: "link") Text("Learn more about Tailscale") .underline(isHovering, color: .accentColor) } - } + }) .buttonStyle(.link) .pointingHandCursor() .onHover { hovering in @@ -417,19 +439,22 @@ struct TailscaleLink: View { } // MARK: - Credit Link Component + struct CreditLink: View { let name: String let url: String @State private var isHovering = false - + var body: some View { Button(action: { - NSWorkspace.shared.open(URL(string: url)!) - }) { + if let linkURL = URL(string: url) { + NSWorkspace.shared.open(linkURL) + } + }, label: { Text(name) .font(.caption) .underline(isHovering, color: .accentColor) - } + }) .buttonStyle(.link) .pointingHandCursor() .onHover { hovering in @@ -441,6 +466,7 @@ struct CreditLink: View { } // MARK: - Preview + struct WelcomeView_Previews: PreviewProvider { static var previews: some View { WelcomeView() diff --git a/VibeTunnel/Utilities/ApplicationMover.swift b/VibeTunnel/Utilities/ApplicationMover.swift index 3adf2c95..29234d14 100644 --- a/VibeTunnel/Utilities/ApplicationMover.swift +++ b/VibeTunnel/Utilities/ApplicationMover.swift @@ -173,7 +173,8 @@ final class ApplicationMover { guard let plist = try PropertyListSerialization .propertyList(from: data, options: [], format: nil) as? [String: Any], - let images = plist["images"] as? [[String: Any]] else { + let images = plist["images"] as? [[String: Any]] + else { logger.debug("ApplicationMover: No disk images found in hdiutil output") return nil } @@ -183,7 +184,8 @@ final class ApplicationMover { if let entities = image["system-entities"] as? [[String: Any]] { for entity in entities { if let entityDevName = entity["dev-entry"] as? String, - entityDevName == deviceName { + entityDevName == deviceName + { logger.debug("Found matching disk image for device: \(deviceName)") return deviceName } @@ -193,7 +195,6 @@ final class ApplicationMover { logger.debug("Device \(deviceName) is not a disk image") return nil - } catch { logger.debug("ApplicationMover: Unable to run hdiutil (expected in some environments): \(error)") return nil @@ -285,7 +286,6 @@ final class ApplicationMover { // Show success message and offer to relaunch showMoveSuccessAndRelaunch(newPath: applicationsPath) - } catch { logger.error("Failed to move app to Applications: \(error)") showMoveError(error) @@ -355,4 +355,4 @@ final class ApplicationMover { alert.alertStyle = .warning alert.runModal() } -} \ No newline at end of file +} diff --git a/VibeTunnel/Utilities/CLIInstaller.swift b/VibeTunnel/Utilities/CLIInstaller.swift index e708d66c..ad5f1b66 100644 --- a/VibeTunnel/Utilities/CLIInstaller.swift +++ b/VibeTunnel/Utilities/CLIInstaller.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -import os.log import Observation +import os.log /// Service responsible for creating symlinks to command line tools with sudo authentication. /// @@ -24,35 +24,35 @@ import Observation @Observable final class CLIInstaller { // MARK: - Properties - + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "CLIInstaller") - + var isInstalled = false var isInstalling = false var lastError: String? - + // MARK: - Public Interface - + /// Checks if the CLI tool is installed func checkInstallationStatus() { let targetPath = "/usr/local/bin/vt" isInstalled = FileManager.default.fileExists(atPath: targetPath) logger.info("CLIInstaller: CLI tool installed: \(self.isInstalled)") } - + /// Installs the CLI tool (async version for WelcomeView) func install() async { await MainActor.run { installCLITool() } } - + /// Installs the vt CLI tool to /usr/local/bin with proper symlink func installCLITool() { logger.info("CLIInstaller: Starting CLI tool installation...") isInstalling = true lastError = nil - + guard let resourcePath = Bundle.main.path(forResource: "vt", ofType: nil) else { logger.error("CLIInstaller: Could not find vt binary in app bundle") lastError = "The vt command line tool could not be found in the application bundle." @@ -60,20 +60,22 @@ final class CLIInstaller { isInstalling = false return } - + let targetPath = "/usr/local/bin/vt" logger.info("CLIInstaller: Resource path: \(resourcePath)") logger.info("CLIInstaller: Target path: \(targetPath)") - + // Check if symlink already exists if FileManager.default.fileExists(atPath: targetPath) { let alert = NSAlert() alert.messageText = "CLI Tool Already Installed" - alert.informativeText = "The 'vt' command line tool is already installed at \(targetPath). Would you like to replace it?" + alert + .informativeText = + "The 'vt' command line tool is already installed at \(targetPath). Would you like to replace it?" alert.addButton(withTitle: "Replace") alert.addButton(withTitle: "Cancel") alert.alertStyle = .informational - + let response = alert.runModal() if response != .alertFirstButtonReturn { logger.info("CLIInstaller: User cancelled replacement") @@ -81,93 +83,95 @@ final class CLIInstaller { return } } - + // Show confirmation dialog let confirmAlert = NSAlert() confirmAlert.messageText = "Install CLI Tool" - confirmAlert.informativeText = "This will create a symlink to the 'vt' command line tool in /usr/local/bin, allowing you to use it from the terminal. Administrator privileges are required." + confirmAlert + .informativeText = + "This will create a symlink to the 'vt' command line tool in /usr/local/bin, allowing you to use it from the terminal. Administrator privileges are required." confirmAlert.addButton(withTitle: "Install") confirmAlert.addButton(withTitle: "Cancel") confirmAlert.alertStyle = .informational confirmAlert.icon = NSApp.applicationIconImage - + let response = confirmAlert.runModal() if response != .alertFirstButtonReturn { logger.info("CLIInstaller: User cancelled installation") isInstalling = false return } - + // Perform the installation performInstallation(from: resourcePath, to: targetPath) } - + // MARK: - Private Implementation - + /// Performs the actual symlink creation with sudo privileges private func performInstallation(from sourcePath: String, to targetPath: String) { logger.info("CLIInstaller: Performing installation from \(sourcePath) to \(targetPath)") - + // Create the /usr/local/bin directory if it doesn't exist let binDirectory = "/usr/local/bin" let script = """ #!/bin/bash set -e - + # Create /usr/local/bin if it doesn't exist if [ ! -d "\(binDirectory)" ]; then mkdir -p "\(binDirectory)" echo "Created directory \(binDirectory)" fi - + # Remove existing symlink if it exists if [ -L "\(targetPath)" ] || [ -f "\(targetPath)" ]; then rm -f "\(targetPath)" echo "Removed existing file at \(targetPath)" fi - + # Create the symlink ln -s "\(sourcePath)" "\(targetPath)" echo "Created symlink from \(sourcePath) to \(targetPath)" - + # Make sure the symlink is executable chmod +x "\(targetPath)" echo "Set executable permissions on \(targetPath)" """ - + // Write the script to a temporary file let tempDir = FileManager.default.temporaryDirectory let scriptURL = tempDir.appendingPathComponent("install_vt_cli.sh") - + do { try script.write(to: scriptURL, atomically: true, encoding: .utf8) - + // Make the script executable let attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o755] try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptURL.path) - + logger.info("CLIInstaller: Created installation script at \(scriptURL.path)") - + // Execute with osascript to get sudo dialog let appleScript = """ do shell script "bash '\(scriptURL.path)'" with administrator privileges """ - + let task = Process() task.launchPath = "/usr/bin/osascript" task.arguments = ["-e", appleScript] - + let pipe = Pipe() let errorPipe = Pipe() task.standardOutput = pipe task.standardError = errorPipe - + try task.run() task.waitUntilExit() - + // Clean up the temporary script try? FileManager.default.removeItem(at: scriptURL) - + if task.terminationStatus == 0 { logger.info("CLIInstaller: Installation completed successfully") isInstalled = true @@ -181,7 +185,6 @@ final class CLIInstaller { isInstalling = false showError("Installation failed: \(errorString)") } - } catch { logger.error("CLIInstaller: Installation failed with error: \(error)") lastError = "Installation failed: \(error.localizedDescription)" @@ -189,7 +192,7 @@ final class CLIInstaller { showError("Installation failed: \(error.localizedDescription)") } } - + /// Shows success message after installation private func showSuccess() { let alert = NSAlert() @@ -200,7 +203,7 @@ final class CLIInstaller { alert.icon = NSApp.applicationIconImage alert.runModal() } - + /// Shows error message for installation failures private func showError(_ message: String) { let alert = NSAlert() @@ -210,4 +213,4 @@ final class CLIInstaller { alert.alertStyle = .critical alert.runModal() } -} \ No newline at end of file +} diff --git a/VibeTunnel/Utilities/SettingsOpener.swift b/VibeTunnel/Utilities/SettingsOpener.swift index dcdb4ac0..ece32151 100644 --- a/VibeTunnel/Utilities/SettingsOpener.swift +++ b/VibeTunnel/Utilities/SettingsOpener.swift @@ -7,7 +7,7 @@ import SwiftUI enum SettingsOpener { /// SwiftUI's hardcoded settings window identifier private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window" - + /// Opens the Settings window using the environment action via notification /// This is needed for cases where we can't use SettingsLink (e.g., from notifications) static func openSettings() { @@ -15,14 +15,14 @@ enum SettingsOpener { let currentPolicy = NSApp.activationPolicy() NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) - + // Try the direct menu item approach first (from VibeMeter) if openSettingsViaMenuItem() { // Successfully opened via menu item Task { try? await Task.sleep(for: .milliseconds(100)) focusSettingsWindow() - + // Restore activation policy after a delay try? await Task.sleep(for: .milliseconds(200)) NSApp.setActivationPolicy(currentPolicy) @@ -30,77 +30,81 @@ enum SettingsOpener { } else { // Fallback to notification approach NotificationCenter.default.post(name: .openSettingsRequest, object: nil) - + Task { try? await Task.sleep(for: .milliseconds(150)) focusSettingsWindow() - + // Restore activation policy after a delay try? await Task.sleep(for: .milliseconds(200)) NSApp.setActivationPolicy(currentPolicy) } } } - + /// Opens settings via the native menu item (more reliable) private static func openSettingsViaMenuItem() -> Bool { let kAppMenuInternalIdentifier = "app" let kSettingsLocalizedStringKey = "Settings\\U2026" - - if let internalItemAction = NSApp.mainMenu?.item( - withInternalIdentifier: kAppMenuInternalIdentifier)?.submenu?.item( - withLocalizedTitle: kSettingsLocalizedStringKey)?.internalItemAction { + + if let internalItemAction = NSApp.mainMenu? + .item(withInternalIdentifier: kAppMenuInternalIdentifier)? + .submenu? + .item(withLocalizedTitle: kSettingsLocalizedStringKey)? + .internalItemAction + { internalItemAction() return true } return false } - + /// Focuses the settings window without level manipulation static func focusSettingsWindow() { // First try the SwiftUI settings window identifier - if let settingsWindow = NSApp.windows.first(where: { - $0.identifier?.rawValue == settingsWindowIdentifier + if let settingsWindow = NSApp.windows.first(where: { + $0.identifier?.rawValue == settingsWindowIdentifier }) { bringWindowToFront(settingsWindow) } else if let settingsWindow = NSApp.windows.first(where: { window in // Fallback to title-based search - window.isVisible && - window.styleMask.contains(.titled) && - (window.title.localizedCaseInsensitiveContains("settings") || - window.title.localizedCaseInsensitiveContains("preferences")) + window.isVisible && + window.styleMask.contains(.titled) && + (window.title.localizedCaseInsensitiveContains("settings") || + window.title.localizedCaseInsensitiveContains("preferences") + ) }) { bringWindowToFront(settingsWindow) } } - + /// Brings a window to front using the most reliable method private static func bringWindowToFront(_ window: NSWindow) { // Ensure window is on screen if window.isMiniaturized { window.deminiaturize(nil) } - + // Center window on the active screen WindowCenteringHelper.centerOnActiveScreen(window) - + // Multiple methods to ensure window comes to front window.makeKeyAndOrderFront(nil) window.orderFrontRegardless() - window.level = .floating // Temporarily set to floating level - + window.level = .floating // Temporarily set to floating level + // Reset level after a short delay Task { @MainActor in try? await Task.sleep(for: .milliseconds(50)) window.level = .normal } - + NSApp.activate(ignoringOtherApps: true) - + // Setup window close observer to restore activation policy setupWindowCloseObserver(for: window) } - + /// Observes when settings window closes to restore activation policy private static func setupWindowCloseObserver(for window: NSWindow) { NotificationCenter.default.addObserver( @@ -118,11 +122,11 @@ enum SettingsOpener { } } } - + /// Opens the Settings window and navigates to a specific tab static func openSettingsTab(_ tab: SettingsTab) { openSettings() - + Task { // Small delay to ensure the settings window is fully initialized try? await Task.sleep(for: .milliseconds(150)) @@ -139,8 +143,9 @@ enum SettingsOpener { /// A hidden window view that enables Settings to work in MenuBarExtra-only apps /// This is a workaround for FB10184971 struct HiddenWindowView: View { - @Environment(\.openSettings) private var openSettings - + @Environment(\.openSettings) + private var openSettings + var body: some View { Color.clear .frame(width: 1, height: 1) @@ -148,11 +153,11 @@ struct HiddenWindowView: View { // Configure the window to be invisible Task { @MainActor in try? await Task.sleep(for: .milliseconds(50)) - + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "HiddenWindow" }) { // Position window offscreen WindowCenteringHelper.positionOffScreen(window) - + window.backgroundColor = .clear window.isOpaque = false window.hasShadow = false @@ -164,7 +169,7 @@ struct HiddenWindowView: View { window.orderOut(nil) } } - + // Listen for settings open requests NotificationCenter.default.addObserver( forName: .openSettingsRequest, @@ -173,7 +178,7 @@ struct HiddenWindowView: View { ) { _ in Task { @MainActor in openSettings() - + // Additional check to bring settings to front after environment action try? await Task.sleep(for: .milliseconds(150)) SettingsOpener.focusSettingsWindow() @@ -195,27 +200,30 @@ extension NSMenuItem { /// An internal SwiftUI menu item identifier that should be a public property on `NSMenuItem`. fileprivate var internalIdentifier: String? { guard let id = Mirror.firstChild( - withLabel: "id", in: self)?.value + withLabel: "id", in: self + )?.value else { return nil } - + return "\(id)" } - + /// A callback which is associated directly with this `NSMenuItem`. fileprivate var internalItemAction: (() -> Void)? { - guard - let platformItemAction = Mirror.firstChild( - withLabel: "platformItemAction", in: self)?.value, + guard let platformItemAction = Mirror.firstChild( + withLabel: "platformItemAction", in: self + )?.value, let typeErasedCallback = Mirror.firstChild( - in: platformItemAction)?.value + in: platformItemAction + )?.value else { return nil } - + return Mirror.firstChild( - in: typeErasedCallback)?.value as? () -> Void + in: typeErasedCallback + )?.value as? () -> Void } } @@ -224,51 +232,54 @@ extension NSMenuItem { extension NSMenu { /// Get the first `NSMenuItem` whose internal identifier string matches the given value. fileprivate func item(withInternalIdentifier identifier: String) -> NSMenuItem? { - items.first(where: { - $0.internalIdentifier?.elementsEqual(identifier) ?? false - }) + items.first { $0.internalIdentifier?.elementsEqual(identifier) ?? false } } - + /// Get the first `NSMenuItem` whose title is equivalent to the localized string referenced /// by the given localized string key in the localization table identified by the given table name /// from the bundle located at the given bundle path. fileprivate func item( withLocalizedTitle localizedTitleKey: String, inTable tableName: String = "MenuCommands", - fromBundle bundlePath: String = "/System/Library/Frameworks/AppKit.framework") -> NSMenuItem? { + fromBundle bundlePath: String = "/System/Library/Frameworks/AppKit.framework" + ) + -> NSMenuItem? + { guard let localizationResource = Bundle(path: bundlePath) else { return nil } - + return item(withTitle: NSLocalizedString( localizedTitleKey, tableName: tableName, bundle: localizationResource, - comment: "")) + comment: "" + )) } } // MARK: - Mirror Extensions (Helper) -private extension Mirror { +extension Mirror { /// The unconditional first child of the reflection subject. - var firstChild: Child? { children.first } - + fileprivate var firstChild: Child? { children.first } + /// The first child of the reflection subject whose label matches the given string. - func firstChild(withLabel label: String) -> Child? { - children.first(where: { - $0.label?.elementsEqual(label) ?? false - }) + fileprivate func firstChild(withLabel label: String) -> Child? { + children.first { $0.label?.elementsEqual(label) ?? false } } - + /// The unconditional first child of the given subject. - static func firstChild(in subject: Any) -> Child? { + fileprivate static func firstChild(in subject: Any) -> Child? { Mirror(reflecting: subject).firstChild } - + /// The first child of the given subject whose label matches the given string. - static func firstChild( - withLabel label: String, in subject: Any) -> Child? { + fileprivate static func firstChild( + withLabel label: String, in subject: Any + ) + -> Child? + { Mirror(reflecting: subject).firstChild(withLabel: label) } -} \ No newline at end of file +} diff --git a/VibeTunnel/Utilities/WelcomeWindowController.swift b/VibeTunnel/Utilities/WelcomeWindowController.swift index e3893e40..cbbf4bdb 100644 --- a/VibeTunnel/Utilities/WelcomeWindowController.swift +++ b/VibeTunnel/Utilities/WelcomeWindowController.swift @@ -1,15 +1,15 @@ -import SwiftUI import AppKit +import SwiftUI /// Handles the presentation of the welcome screen window @MainActor final class WelcomeWindowController: NSWindowController { static let shared = WelcomeWindowController() - + private init() { let welcomeView = WelcomeView() let hostingController = NSHostingController(rootView: welcomeView) - + let window = NSWindow(contentViewController: hostingController) window.title = "" window.styleMask = [.titled, .closable, .fullSizeContentView] @@ -19,9 +19,9 @@ final class WelcomeWindowController: NSWindowController { window.setFrameAutosaveName("WelcomeWindow") window.isReleasedWhenClosed = false window.level = .floating - + super.init(window: window) - + // Listen for notification to show welcome screen NotificationCenter.default.addObserver( self, @@ -30,27 +30,30 @@ final class WelcomeWindowController: NSWindowController { object: nil ) } - + + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func show() { - guard let window = window else { return } - + guard let window else { return } + // Center window on the active screen (screen with mouse cursor) WindowCenteringHelper.centerOnActiveScreen(window) - + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } - - @objc private func handleShowWelcomeNotification() { + + @objc + private func handleShowWelcomeNotification() { show() } } // MARK: - Notification Extension + extension Notification.Name { static let showWelcomeScreen = Notification.Name("showWelcomeScreen") -} \ No newline at end of file +} diff --git a/VibeTunnel/Utilities/WindowSizeAnimator.swift b/VibeTunnel/Utilities/WindowSizeAnimator.swift index 9ce5fffb..eb7bc635 100644 --- a/VibeTunnel/Utilities/WindowSizeAnimator.swift +++ b/VibeTunnel/Utilities/WindowSizeAnimator.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI import Observation +import SwiftUI /// A custom window size animator that works with SwiftUI Settings windows @MainActor diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index f457f934..7eb87260 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -1,4 +1,5 @@ import AppKit +import os.log import SwiftUI /// Main entry point for the VibeTunnel macOS application @@ -19,7 +20,7 @@ struct VibeTunnelApp: App { .windowResizability(.contentSize) .defaultSize(width: 1, height: 1) .windowStyle(.hiddenTitleBar) - + // Welcome Window WindowGroup("Welcome", id: "welcome") { WelcomeView() @@ -27,15 +28,15 @@ struct VibeTunnelApp: App { .windowResizability(.contentSize) .defaultSize(width: 580, height: 480) .windowStyle(.hiddenTitleBar) - + Settings { SettingsView() } .commands { CommandGroup(after: .appInfo) { - SettingsLink(label: { + SettingsLink { Text("About VibeTunnel") - }) + } .simultaneousGesture(TapGesture().onEnded { // Navigate to About tab after settings opens Task { @@ -71,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let sessionMonitor = SessionMonitor.shared private let serverMonitor = ServerMonitor.shared private let ngrokService = NgrokService.shared + private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "AppDelegate") /// Distributed notification name used to ask an existing instance to show the Settings window. private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings") @@ -86,7 +88,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if !isRunningInPreview, !isRunningInTests, !isRunningInDebug { handleSingleInstanceCheck() registerForDistributedNotifications() - + // Check if app needs to be moved to Applications folder let applicationMover = ApplicationMover() applicationMover.checkAndOfferToMoveToApplications() @@ -105,7 +107,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showWelcomeScreen() } - // Listen for update check requests NotificationCenter.default.addObserver( self, @@ -113,17 +114,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { name: Notification.Name("checkForUpdates"), object: nil ) - // Initialize and start HTTP server using ServerManager Task { do { - print("Attempting to start HTTP server using ServerManager...") + logger.info("Attempting to start HTTP server using ServerManager...") await serverManager.start() - - print("HTTP server started successfully on port \(serverManager.port)") - print("Server is running: \(serverManager.isRunning)") - print("Server mode: \(serverManager.serverMode.displayName)") + + logger.info("HTTP server started successfully on port \(self.serverManager.port)") + logger.info("Server is running: \(self.serverManager.isRunning)") + logger.info("Server mode: \(self.serverManager.serverMode.displayName)") // Start monitoring sessions after server starts sessionMonitor.startMonitoring() @@ -133,23 +133,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/health") { let (_, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse { - print("Server health check response: \(httpResponse.statusCode)") + logger.info("Server health check response: \(httpResponse.statusCode)") } } } catch { - print("Failed to start HTTP server: \(error)") - print("Error type: \(type(of: error))") - print("Error description: \(error.localizedDescription)") + logger.error("Failed to start HTTP server: \(error)") + logger.error("Error type: \(type(of: error))") + logger.error("Error description: \(error.localizedDescription)") if let nsError = error as NSError? { - print("NSError domain: \(nsError.domain)") - print("NSError code: \(nsError.code)") - print("NSError userInfo: \(nsError.userInfo)") + logger.error("NSError domain: \(nsError.domain)") + logger.error("NSError code: \(nsError.code)") + logger.error("NSError userInfo: \(nsError.userInfo)") } } } } - private func handleSingleInstanceCheck() { let runningApps = NSRunningApplication .runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") @@ -194,14 +193,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func handleCheckForUpdatesNotification() { sparkleUpdaterManager?.checkForUpdates() } - + /// Shows the welcome screen private func showWelcomeScreen() { // Initialize the welcome window controller (singleton will handle the rest) _ = WelcomeWindowController.shared WelcomeWindowController.shared.show() } - + /// Public method to show welcome screen (can be called from settings) static func showWelcomeScreen() { WelcomeWindowController.shared.show() @@ -239,5 +238,3 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) } } - -