From c26be3eefd7507fb7eaf595414adf3e659eac481 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Jun 2025 23:45:44 +0200 Subject: [PATCH] Update to version 1.0.0 build 100 and fix all linting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set version to 1.0.0 and build number to 100 - Run SwiftFormat to format all Swift files - Fix all SwiftLint warnings and errors: - Replace force unwrapping with safe optional handling - Fix redundant string enum values - Replace print statements with proper Logger - Fix identifier names (w→width, h→height, a→first, b→second) - Fix attributes formatting - Fix vertical whitespace issues - Fix multiple closures with trailing closure syntax - Configure SwiftFormat and SwiftLint for Swift 6 compatibility: - Disable redundantSelf rule to preserve required self references - Set --self insert to maintain Swift 6 compliance - Add comments about Swift 6 requirements - Ensure linting and formatting tools don't create conflicts --- .swiftformat | 3 +- .swiftlint.yml | 2 + VibeTunnel.xcodeproj/project.pbxproj | 20 +- .../UserInterfaceState.xcuserstate | Bin 172096 -> 172533 bytes .../Core/Models/DashboardAccessMode.swift | 12 +- .../Core/Services/BasicAuthMiddleware.swift | 40 ++- .../Core/Services/CastFileGenerator.swift | 99 +++--- .../Core/Services/DashboardKeychain.swift | 46 +-- .../Core/Services/HummingbirdServer.swift | 75 ++-- VibeTunnel/Core/Services/NgrokService.swift | 27 +- VibeTunnel/Core/Services/RustServer.swift | 252 ++++++++------ VibeTunnel/Core/Services/ServerManager.swift | 132 ++++--- VibeTunnel/Core/Services/ServerMonitor.swift | 21 +- VibeTunnel/Core/Services/ServerProtocol.swift | 43 +-- VibeTunnel/Core/Services/SessionMonitor.swift | 15 +- .../Core/Services/SparkleUpdaterManager.swift | 83 ++--- .../Core/Services/TTYForwardManager.swift | 6 +- VibeTunnel/Core/Services/TunnelClient.swift | 2 +- VibeTunnel/Core/Services/TunnelServer.swift | 327 ++++++++++-------- .../Utilities/WindowCenteringHelper.swift | 24 +- .../Presentation/Views/MenuBarView.swift | 53 ++- .../Views/ServerConsoleView.swift | 98 +++--- .../Presentation/Views/SettingsView.swift | 214 +++++++----- .../Presentation/Views/WelcomeView.swift | 216 +++++++----- VibeTunnel/Utilities/ApplicationMover.swift | 10 +- VibeTunnel/Utilities/CLIInstaller.swift | 77 +++-- VibeTunnel/Utilities/SettingsOpener.swift | 135 ++++---- .../Utilities/WelcomeWindowController.swift | 29 +- VibeTunnel/Utilities/WindowSizeAnimator.swift | 2 +- VibeTunnel/VibeTunnelApp.swift | 45 ++- 30 files changed, 1133 insertions(+), 975 deletions(-) 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 344fc984830780f5dce2c8b560657276bdd003b4..48001bab99f8d04d5e213b3fd00696883ea227ca 100644 GIT binary patch literal 172533 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 literal 172096 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 I4_}`8Ki5dX)c^nh 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 { ) } } - -