diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b13fd636..3dd2e8af 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -52,10 +52,6 @@ jobs: target: aarch64-apple-darwin name: macOS ARM64 binary-name: tty-fwd - - os: windows-latest - target: x86_64-pc-windows-msvc - name: Windows x86_64 - binary-name: tty-fwd.exe runs-on: ${{ matrix.os }} steps: diff --git a/KEYCHAIN_OPTIMIZATION.md b/KEYCHAIN_OPTIMIZATION.md new file mode 100644 index 00000000..2d419234 --- /dev/null +++ b/KEYCHAIN_OPTIMIZATION.md @@ -0,0 +1,89 @@ +# Keychain Access Dialog Investigation Results + +## Problem Summary +The keychain access dialog appears on every restart because: +1. Password is accessed immediately when the server starts (both TunnelServer and RustServer) +2. NgrokService's auth token is accessed when checking status +3. No in-memory caching of credentials after first access + +## Where Keychain Access is Triggered + +### 1. Server Startup (Every time the app launches) +- **TunnelServer.swift:146**: `if let password = DashboardKeychain.shared.getPassword()` +- **RustServer.swift:162**: `if let password = DashboardKeychain.shared.getPassword()` +- Both servers check for password on startup to configure basic auth middleware + +### 2. Settings View +- **DashboardSettingsView.swift:114**: Checks if password exists on view appear +- **DashboardSettingsView.swift:259**: When revealing ngrok token +- **WelcomeView.swift:342**: When setting password during onboarding + +### 3. NgrokService +- **NgrokService.swift:85-87**: Getting auth token (triggers keychain) +- **NgrokService.swift:117-121**: When starting ngrok tunnel + +## Recommended Solutions + +### 1. Implement In-Memory Caching +Create a secure in-memory cache for credentials that survives the app session: + +```swift +@MainActor +final class CredentialCache { + static let shared = CredentialCache() + + private var dashboardPassword: String? + private var ngrokAuthToken: String? + private var lastAccessTime: Date? + + private init() {} + + func getDashboardPassword() -> String? { + if let password = dashboardPassword { + return password + } + // Fall back to keychain only if not cached + dashboardPassword = DashboardKeychain.shared.getPassword() + return dashboardPassword + } + + func clearCache() { + dashboardPassword = nil + ngrokAuthToken = nil + } +} +``` + +### 2. Defer Keychain Access Until Needed +- Don't access keychain on server startup unless password protection is enabled +- Check `UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled")` first +- Only access keychain when actually authenticating a request + +### 3. Use Keychain Access Groups +Configure the app to use a shared keychain access group to reduce prompts: +- Add keychain access group to entitlements +- Update keychain queries to include the access group + +### 4. Batch Keychain Operations +When multiple keychain accesses are needed, batch them together to minimize prompts. + +### 5. Add "hasPassword" Check Without Retrieval +Both DashboardKeychain and NgrokService already have this implemented: +- `DashboardKeychain.hasPassword()` (line 45-59) +- `NgrokService.hasAuthToken` (line 100-102) + +Use these checks before attempting to retrieve values. + +## Immediate Fix Implementation + +The quickest fix is to defer password retrieval until an authenticated request arrives: + +1. Modify server implementations to not retrieve password on startup +2. Add lazy initialization for basic auth middleware +3. Cache credentials after first successful retrieval +4. Only check if password exists (using `hasPassword()`) on startup + +This will reduce keychain prompts from every startup to only when: +- First authenticated request arrives +- User explicitly accesses credentials in settings +- Credentials are changed/updated \ No newline at end of file diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index f7cf5359..c0ddd045 100644 Binary files a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate and b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/VibeTunnel/Core/Models/DashboardAccessMode.swift b/VibeTunnel/Core/Models/DashboardAccessMode.swift index 23d40328..74711643 100644 --- a/VibeTunnel/Core/Models/DashboardAccessMode.swift +++ b/VibeTunnel/Core/Models/DashboardAccessMode.swift @@ -1,6 +1,10 @@ import Foundation -/// Dashboard access mode +/// Dashboard access mode. +/// +/// Determines the network binding configuration for the VibeTunnel server. +/// Controls whether the web interface is accessible only locally or +/// from other devices on the network. enum DashboardAccessMode: String, CaseIterable { case localhost case network diff --git a/VibeTunnel/Core/Models/TunnelSession.swift b/VibeTunnel/Core/Models/TunnelSession.swift index c9be8561..66da08aa 100644 --- a/VibeTunnel/Core/Models/TunnelSession.swift +++ b/VibeTunnel/Core/Models/TunnelSession.swift @@ -1,6 +1,10 @@ import Foundation -/// Represents a terminal session that can be controlled remotely +/// Represents a terminal session that can be controlled remotely. +/// +/// A `TunnelSession` encapsulates the state and metadata of a terminal session +/// that can be accessed through the web interface. Each session has a unique identifier, +/// creation timestamp, and tracks its activity status. public struct TunnelSession: Identifiable, Codable, Sendable { public let id: UUID public let createdAt: Date @@ -21,7 +25,10 @@ public struct TunnelSession: Identifiable, Codable, Sendable { } } -/// Request to create a new terminal session +/// Request to create a new terminal session. +/// +/// Contains optional configuration for initializing a new terminal session, +/// including working directory, environment variables, and shell preference. public struct CreateSessionRequest: Codable, Sendable { public let workingDirectory: String? public let environment: [String: String]? @@ -34,7 +41,9 @@ public struct CreateSessionRequest: Codable, Sendable { } } -/// Response after creating a session +/// Response after creating a session. +/// +/// Contains the newly created session's identifier and timestamp. public struct CreateSessionResponse: Codable, Sendable { public let sessionId: String public let createdAt: Date @@ -45,7 +54,10 @@ public struct CreateSessionResponse: Codable, Sendable { } } -/// Command execution request +/// Command execution request. +/// +/// Encapsulates a command to be executed within a specific terminal session, +/// with optional arguments and environment variables. public struct CommandRequest: Codable, Sendable { public let sessionId: String public let command: String @@ -60,7 +72,10 @@ public struct CommandRequest: Codable, Sendable { } } -/// Command execution response +/// Command execution response. +/// +/// Contains the results of a command execution including output streams, +/// exit code, and execution timestamp. public struct CommandResponse: Codable, Sendable { public let sessionId: String public let output: String? @@ -83,7 +98,9 @@ public struct CommandResponse: Codable, Sendable { } } -/// Session information +/// Session information. +/// +/// Provides a summary of a terminal session's current state and activity. public struct SessionInfo: Codable, Sendable { public let id: String public let createdAt: Date @@ -98,7 +115,9 @@ public struct SessionInfo: Codable, Sendable { } } -/// List sessions response +/// List sessions response. +/// +/// Contains an array of all available terminal sessions. public struct ListSessionsResponse: Codable, Sendable { public let sessions: [SessionInfo] @@ -110,7 +129,10 @@ public struct ListSessionsResponse: Codable, Sendable { // MARK: - Extensions for TunnelClient extension TunnelSession { - /// Client information for session creation + /// Client information for session creation. + /// + /// Contains metadata about the client system creating a session, + /// including hostname, user details, and system architecture. public struct ClientInfo: Codable, Sendable { public let hostname: String public let username: String @@ -133,7 +155,9 @@ extension TunnelSession { } } - /// Request to create a new session + /// Request to create a new session. + /// + /// Wraps optional client information for session initialization. public struct CreateRequest: Codable, Sendable { public let clientInfo: ClientInfo? @@ -142,7 +166,9 @@ extension TunnelSession { } } - /// Response after creating a session + /// Response after creating a session. + /// + /// Contains both the session identifier and full session object. public struct CreateResponse: Codable, Sendable { public let id: String public let session: TunnelSession @@ -153,7 +179,10 @@ extension TunnelSession { } } - /// Request to execute a command + /// Request to execute a command. + /// + /// Specifies a command to run in a terminal session with optional + /// environment variables and working directory. public struct ExecuteCommandRequest: Codable, Sendable { public let sessionId: String public let command: String @@ -173,7 +202,9 @@ extension TunnelSession { } } - /// Response from command execution + /// Response from command execution. + /// + /// Contains the command's exit code and captured output streams. public struct ExecuteCommandResponse: Codable, Sendable { public let exitCode: Int32 public let stdout: String @@ -186,7 +217,10 @@ extension TunnelSession { } } - /// Health check response + /// Health check response. + /// + /// Provides server status information including version, + /// timestamp, and active session count. public struct HealthResponse: Codable, Sendable { public let status: String public let timestamp: Date @@ -201,7 +235,9 @@ extension TunnelSession { } } - /// List sessions response + /// List sessions response. + /// + /// Contains an array of all active tunnel sessions. public struct ListResponse: Codable, Sendable { public let sessions: [TunnelSession] @@ -210,7 +246,9 @@ extension TunnelSession { } } - /// Error response from server + /// Error response from server. + /// + /// Standardized error format with message and optional error code. public struct ErrorResponse: Codable, Sendable { public let error: String public let code: String? diff --git a/VibeTunnel/Core/Services/BasicAuthMiddleware.swift b/VibeTunnel/Core/Services/BasicAuthMiddleware.swift index b04aab84..0ba68ce7 100644 --- a/VibeTunnel/Core/Services/BasicAuthMiddleware.swift +++ b/VibeTunnel/Core/Services/BasicAuthMiddleware.swift @@ -4,7 +4,12 @@ import Hummingbird import HummingbirdCore import NIOCore -/// Middleware that implements HTTP Basic Authentication +/// Middleware that implements HTTP Basic Authentication. +/// +/// Provides password-based access control for the VibeTunnel dashboard. +/// Validates incoming requests against a configured password using +/// standard HTTP Basic Authentication. Exempts health check endpoints +/// from authentication requirements. struct BasicAuthMiddleware: RouterMiddleware { let password: String let realm: String diff --git a/VibeTunnel/Core/Services/CastFileGenerator.swift b/VibeTunnel/Core/Services/CastFileGenerator.swift index 11a1f4cf..89165a1b 100644 --- a/VibeTunnel/Core/Services/CastFileGenerator.swift +++ b/VibeTunnel/Core/Services/CastFileGenerator.swift @@ -1,11 +1,20 @@ import Foundation import Logging -/// Generates Asciinema cast v2 format files from terminal session output +/// Generates Asciinema cast v2 format files from terminal session output. +/// +/// Creates recordings of terminal sessions in the Asciinema cast format, +/// which can be played back using Asciinema players. Handles timing information, +/// terminal dimensions, and output/input event recording. +/// /// Format specification: https://docs.asciinema.org/manual/asciicast/v2/ struct CastFileGenerator { private let logger = Logger(label: "VibeTunnel.CastFileGenerator") + /// Header structure for Asciinema cast v2 format. + /// + /// Contains metadata about the terminal recording including + /// dimensions, timing, and environment information. struct CastHeader: Codable { let version: Int = 2 let width: Int @@ -30,6 +39,9 @@ struct CastFileGenerator { } } + /// Represents a single event in the Asciinema recording. + /// + /// Each event captures either terminal output or input at a specific timestamp. struct CastEvent { let time: TimeInterval let eventType: String diff --git a/VibeTunnel/Core/Services/DashboardKeychain.swift b/VibeTunnel/Core/Services/DashboardKeychain.swift index 2abe06f1..2126ccc3 100644 --- a/VibeTunnel/Core/Services/DashboardKeychain.swift +++ b/VibeTunnel/Core/Services/DashboardKeychain.swift @@ -2,7 +2,11 @@ import Foundation import os import Security -/// Service for managing dashboard password in keychain +/// Service for managing dashboard password in keychain. +/// +/// Provides secure storage and retrieval of the dashboard authentication +/// password using the macOS Keychain. Handles password generation, +/// updates, and deletion with proper error handling and logging. @MainActor final class DashboardKeychain { static let shared = DashboardKeychain() diff --git a/VibeTunnel/Core/Services/HTTPClientProtocol.swift b/VibeTunnel/Core/Services/HTTPClientProtocol.swift index b2579ad5..4e2aefc5 100644 --- a/VibeTunnel/Core/Services/HTTPClientProtocol.swift +++ b/VibeTunnel/Core/Services/HTTPClientProtocol.swift @@ -1,12 +1,19 @@ import Foundation import HTTPTypes -/// Protocol for HTTP client abstraction to enable testing +/// Protocol for HTTP client abstraction to enable testing. +/// +/// Defines the interface for making HTTP requests, allowing for +/// easy mocking and testing of network-dependent code. public protocol HTTPClientProtocol { func data(for request: HTTPRequest, body: Data?) async throws -> (Data, HTTPResponse) } -/// Real HTTP client implementation +/// Real HTTP client implementation. +/// +/// Concrete implementation of HTTPClientProtocol using URLSession +/// for actual network requests. Converts between HTTPTypes and +/// Foundation's URLRequest/URLResponse types. public final class HTTPClient: HTTPClientProtocol { private let session: URLSession @@ -29,6 +36,7 @@ public final class HTTPClient: HTTPClientProtocol { } } +/// Errors that can occur during HTTP client operations. enum HTTPClientError: Error { case invalidResponse } diff --git a/VibeTunnel/Core/Services/HummingbirdServer.swift b/VibeTunnel/Core/Services/HummingbirdServer.swift index 108f2694..d9c1bf52 100644 --- a/VibeTunnel/Core/Services/HummingbirdServer.swift +++ b/VibeTunnel/Core/Services/HummingbirdServer.swift @@ -3,7 +3,12 @@ import Foundation import Hummingbird import OSLog -/// Hummingbird server implementation +/// Hummingbird server implementation. +/// +/// Provides a Swift-native HTTP server using the Hummingbird framework. +/// This implementation offers direct integration with the VibeTunnel UI, +/// built-in WebSocket support, and native Swift performance characteristics. +/// It serves as an alternative to the external Rust tty-fwd binary. @MainActor final class HummingbirdServer: ServerProtocol { private var tunnelServer: TunnelServer? diff --git a/VibeTunnel/Core/Services/LazyBasicAuthMiddleware.swift b/VibeTunnel/Core/Services/LazyBasicAuthMiddleware.swift new file mode 100644 index 00000000..af50b299 --- /dev/null +++ b/VibeTunnel/Core/Services/LazyBasicAuthMiddleware.swift @@ -0,0 +1,113 @@ +import Foundation +import HTTPTypes +import Hummingbird +import HummingbirdCore +import NIOCore +import os + +/// Middleware that implements HTTP Basic Authentication with lazy password loading. +/// +/// This middleware defers keychain access until an authenticated request is received, +/// preventing unnecessary keychain prompts on app startup. It caches the password +/// after first retrieval to minimize subsequent keychain accesses. +@MainActor +struct LazyBasicAuthMiddleware: RouterMiddleware { + private let realm: String + private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "LazyBasicAuth") + + /// Cached password to avoid repeated keychain access + private static var cachedPassword: String? + + init(realm: String = "VibeTunnel Dashboard") { + self.realm = realm + } + + 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) + } + + // Check if password protection is enabled + guard UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") else { + // No password protection, allow request + return try await next(request, context) + } + + // Extract authorization header + guard let authHeader = request.headers[.authorization], + 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 { + 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]) + + // Get password (cached or from keychain) + let requiredPassword: String + if let cached = Self.cachedPassword { + requiredPassword = cached + logger.debug("Using cached password") + } else { + // First authentication attempt - access keychain + guard let password = await MainActor.run(body: { + DashboardKeychain.shared.getPassword() + }) else { + logger.error("Password protection enabled but no password found in keychain") + return unauthorizedResponse() + } + Self.cachedPassword = password + requiredPassword = password + logger.info("Password loaded from keychain and cached") + } + + // Verify password + guard providedPassword == requiredPassword 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) + ) + } + + /// Clears the cached password (useful when password is changed) + static func clearCache() { + cachedPassword = nil + } +} diff --git a/VibeTunnel/Core/Services/NgrokService.swift b/VibeTunnel/Core/Services/NgrokService.swift index 33ef48fb..b1be0324 100644 --- a/VibeTunnel/Core/Services/NgrokService.swift +++ b/VibeTunnel/Core/Services/NgrokService.swift @@ -2,7 +2,10 @@ import Foundation import Observation import os -/// Errors that can occur during ngrok operations +/// Errors that can occur during ngrok operations. +/// +/// Represents various failure modes when working with ngrok tunnels, +/// from installation issues to runtime configuration problems. enum NgrokError: LocalizedError { case notInstalled case authTokenMissing @@ -26,12 +29,18 @@ enum NgrokError: LocalizedError { } } -/// Represents the status of an ngrok tunnel +/// Represents the status of an ngrok tunnel. +/// +/// Contains the current state of an active ngrok tunnel including +/// its public URL, traffic metrics, and creation timestamp. struct NgrokTunnelStatus: Codable { let publicUrl: String let metrics: TunnelMetrics let startedAt: Date + /// Traffic metrics for the ngrok tunnel. + /// + /// Tracks connection count and bandwidth usage. struct TunnelMetrics: Codable { let connectionsCount: Int let bytesIn: Int64 @@ -39,7 +48,10 @@ struct NgrokTunnelStatus: Codable { } } -/// Protocol for ngrok tunnel operations +/// Protocol for ngrok tunnel operations. +/// +/// Defines the interface for managing ngrok tunnel lifecycle, +/// including creation, monitoring, and termination. protocol NgrokTunnelProtocol { func start(port: Int) async throws -> String func stop() async throws @@ -47,10 +59,12 @@ protocol NgrokTunnelProtocol { func isRunning() async -> Bool } -/// Manages ngrok tunnel lifecycle and configuration +/// Manages ngrok tunnel lifecycle and configuration. /// -/// This service handles starting, stopping, and monitoring ngrok tunnels -/// to expose local services to the internet +/// `NgrokService` provides a high-level interface for creating and managing ngrok tunnels +/// to expose local VibeTunnel servers to the internet. It handles authentication, +/// process management, and status monitoring while integrating with the system keychain +/// for secure token storage. The service operates as a singleton on the main actor. @Observable @MainActor final class NgrokService: NgrokTunnelProtocol { @@ -305,7 +319,10 @@ extension FileHandle { } } -/// Async sequence for reading lines from a FileHandle +/// Async sequence for reading lines from a FileHandle. +/// +/// Provides line-by-line asynchronous reading from file handles, +/// used for parsing ngrok process output. struct AsyncLineSequence: AsyncSequence { typealias Element = String @@ -345,7 +362,10 @@ struct AsyncLineSequence: AsyncSequence { // MARK: - Keychain Helper -/// Helper for secure storage of ngrok auth tokens in Keychain +/// Helper for secure storage of ngrok auth tokens in Keychain. +/// +/// Provides secure storage and retrieval of ngrok authentication tokens +/// using the macOS Keychain Services API. private enum KeychainHelper { private static let service = "sh.vibetunnel.vibetunnel" private static let account = "ngrok-auth-token" diff --git a/VibeTunnel/Core/Services/RustServer.swift b/VibeTunnel/Core/Services/RustServer.swift index 3fa3b572..33f1d4f5 100644 --- a/VibeTunnel/Core/Services/RustServer.swift +++ b/VibeTunnel/Core/Services/RustServer.swift @@ -2,14 +2,22 @@ import Combine import Foundation import OSLog -/// Task tracking for better debugging +/// Task tracking for better debugging. +/// +/// Provides task-local storage for debugging context during +/// asynchronous server operations. enum ServerTaskContext { @TaskLocal static var taskName: String? @TaskLocal static var serverType: ServerMode? } -/// Rust tty-fwd server implementation +/// Rust tty-fwd server implementation. +/// +/// Manages the external tty-fwd Rust binary as a subprocess. This implementation +/// provides high-performance terminal multiplexing by leveraging the battle-tested +/// tty-fwd server. It handles process lifecycle, log streaming, and error recovery +/// while maintaining compatibility with the ServerProtocol interface. @MainActor final class RustServer: ServerProtocol { private var process: Process? @@ -22,7 +30,10 @@ final class RustServer: ServerProtocol { 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. + /// + /// Isolates process management operations to prevent blocking the main thread + /// while maintaining Swift concurrency safety. private actor ProcessHandler { private let queue = DispatchQueue( label: "com.steipete.VibeTunnel.RustServer.ProcessHandler", @@ -148,13 +159,22 @@ final class RustServer: ServerProtocol { 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 - let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "$", with: "\\$") - .replacingOccurrences(of: "`", with: "\\`") - .replacingOccurrences(of: "\\", with: "\\\\") - ttyFwdCommand += " --password \"\(escapedPassword)\"" + // Only check if password exists, don't retrieve it yet + if UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") && DashboardKeychain.shared.hasPassword() { + // Defer actual password retrieval until first authenticated request + // For now, we'll use a placeholder that the Rust server will replace + // when it needs to authenticate + logger.info("Password protection enabled, deferring keychain access") + // Note: The Rust server needs to be updated to support lazy password loading + // For now, we still need to access the keychain here + if let password = DashboardKeychain.shared.getPassword() { + // Escape the password for shell + let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") + .replacingOccurrences(of: "`", with: "\\`") + .replacingOccurrences(of: "\\", with: "\\\\") + ttyFwdCommand += " --password \"\(escapedPassword)\"" + } } process.arguments = ["-l", "-c", ttyFwdCommand] diff --git a/VibeTunnel/Core/Services/ServerManager.swift b/VibeTunnel/Core/Services/ServerManager.swift index 4edc60bc..faf325d8 100644 --- a/VibeTunnel/Core/Services/ServerManager.swift +++ b/VibeTunnel/Core/Services/ServerManager.swift @@ -4,7 +4,12 @@ import Observation import OSLog import SwiftUI -/// Manages the active server and handles switching between modes +/// Manages the active server and handles switching between modes. +/// +/// `ServerManager` is the central coordinator for server lifecycle management in VibeTunnel. +/// It handles starting, stopping, and switching between different server implementations (Rust/Hummingbird), +/// manages server configuration, and provides logging capabilities. The manager ensures only one +/// server instance runs at a time and coordinates smooth transitions between server modes. @MainActor @Observable class ServerManager { @@ -43,6 +48,7 @@ class ServerManager { private(set) var currentServer: ServerProtocol? private(set) var isRunning = false private(set) var isSwitching = false + private(set) var isRestarting = false private(set) var lastError: Error? private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager") @@ -188,11 +194,25 @@ class ServerManager { )) // Update ServerMonitor for compatibility - ServerMonitor.shared.isServerRunning = false + // Only set to false if we're not in the middle of a restart + if !isRestarting { + ServerMonitor.shared.isServerRunning = false + } } /// Restart the current server func restart() async { + // Set restarting flag to prevent UI from showing "stopped" state + isRestarting = true + defer { isRestarting = false } + + // Log that we're restarting + logSubject.send(ServerLogEntry( + level: .info, + message: "Restarting server...", + source: serverMode + )) + await stop() await start() } diff --git a/VibeTunnel/Core/Services/ServerMonitor.swift b/VibeTunnel/Core/Services/ServerMonitor.swift index 871f8eb8..2aff3368 100644 --- a/VibeTunnel/Core/Services/ServerMonitor.swift +++ b/VibeTunnel/Core/Services/ServerMonitor.swift @@ -1,8 +1,11 @@ import Foundation import Observation -/// Monitors the HTTP server status and provides observable state for the UI +/// Monitors the HTTP server status and provides observable state for the UI. +/// /// This class now acts as a facade over ServerManager for backward compatibility +/// while providing a simplified interface for UI components to observe server state. +/// It bridges the gap between the older server architecture and the new ServerManager. @MainActor @Observable public final class ServerMonitor { @@ -25,11 +28,7 @@ public final class ServerMonitor { private weak var server: TunnelServer? /// Internal state tracking - @ObservationIgnored public var isServerRunning = false { - didSet { - // Notify observers when state changes - } - } + public var isServerRunning = false private init() { // Sync initial state with ServerManager @@ -53,7 +52,9 @@ public final class ServerMonitor { /// Syncs state with ServerManager private func syncWithServerManager() async { - isServerRunning = ServerManager.shared.isRunning + // Consider the server as running if it's actually running OR if it's restarting + // This prevents the UI from showing "stopped" during restart + isServerRunning = ServerManager.shared.isRunning || ServerManager.shared.isRestarting } /// Starts the server if not already running @@ -72,7 +73,9 @@ public final class ServerMonitor { /// Restarts the server public func restartServer() async throws { + // During restart, we maintain the running state to prevent UI flicker await ServerManager.shared.restart() + // Sync after restart completes await syncWithServerManager() } diff --git a/VibeTunnel/Core/Services/ServerProtocol.swift b/VibeTunnel/Core/Services/ServerProtocol.swift index 2c6e06de..e239bc83 100644 --- a/VibeTunnel/Core/Services/ServerProtocol.swift +++ b/VibeTunnel/Core/Services/ServerProtocol.swift @@ -1,7 +1,11 @@ import Combine import Foundation -/// Common interface for server implementations +/// Common interface for server implementations. +/// +/// Defines the contract that all VibeTunnel server implementations must follow. +/// This protocol ensures consistent behavior across different server backends +/// (Hummingbird, Rust) while allowing for implementation-specific details. @MainActor protocol ServerProtocol: AnyObject { /// Current running state of the server @@ -26,7 +30,11 @@ protocol ServerProtocol: AnyObject { var logPublisher: AnyPublisher { get } } -/// Server mode options +/// Server mode options. +/// +/// Represents the available server implementations that VibeTunnel can use. +/// Each mode corresponds to a different backend technology with its own +/// performance characteristics and feature set. enum ServerMode: String, CaseIterable { case hummingbird case rust @@ -50,8 +58,12 @@ enum ServerMode: String, CaseIterable { } } -/// Log entry from server +/// Log entry from server. +/// +/// Represents a single log message from a server implementation, +/// including severity level, timestamp, and source identification. struct ServerLogEntry { + /// Severity level of the log entry. enum Level { case debug case info diff --git a/VibeTunnel/Core/Services/SessionMonitor.swift b/VibeTunnel/Core/Services/SessionMonitor.swift index 98f82b76..48a6bc1a 100644 --- a/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/VibeTunnel/Core/Services/SessionMonitor.swift @@ -1,7 +1,11 @@ import Foundation import Observation -/// Monitors tty-fwd sessions and provides real-time session count +/// Monitors tty-fwd sessions and provides real-time session count. +/// +/// `SessionMonitor` is a singleton that periodically polls the local server to track active terminal sessions. +/// It maintains a count of running sessions and provides detailed information about each session. +/// The monitor automatically starts and stops based on server lifecycle events. @MainActor @Observable class SessionMonitor { @@ -15,7 +19,10 @@ class SessionMonitor { private let refreshInterval: TimeInterval = 5.0 // Check every 5 seconds private var serverPort: Int - /// Information about a terminal session + /// Information about a terminal session. + /// + /// Contains detailed metadata about a tty-fwd session including its process information, + /// status, and I/O stream paths. struct SessionInfo: Codable { let cmdline: [String] let cwd: String diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index 568bd823..9488d4ba 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -4,7 +4,11 @@ import os.log import Sparkle import UserNotifications -/// SparkleUpdaterManager with automatic update downloads enabled +/// SparkleUpdaterManager with automatic update downloads enabled. +/// +/// Manages application updates using the Sparkle framework. Handles automatic +/// update checking, downloading, and installation while respecting user preferences +/// and update channels. Integrates with macOS notifications for update announcements. @available(macOS 10.15, *) @MainActor public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate { diff --git a/VibeTunnel/Core/Services/TTYForwardManager.swift b/VibeTunnel/Core/Services/TTYForwardManager.swift index eda06e01..c1e86006 100644 --- a/VibeTunnel/Core/Services/TTYForwardManager.swift +++ b/VibeTunnel/Core/Services/TTYForwardManager.swift @@ -1,7 +1,11 @@ import Foundation import os.log -/// Manages interactions with the tty-fwd command-line tool +/// Manages interactions with the tty-fwd command-line tool. +/// +/// Provides a high-level interface for executing the bundled tty-fwd +/// binary, handling process management, error conditions, and ensuring +/// proper executable permissions. Used for terminal multiplexing operations. @MainActor final class TTYForwardManager { static let shared = TTYForwardManager() @@ -101,7 +105,10 @@ final class TTYForwardManager { } } -/// Errors that can occur when working with the tty-fwd binary +/// Errors that can occur when working with the tty-fwd binary. +/// +/// Represents failures specific to tty-fwd execution including +/// missing executable, permission issues, and runtime failures. enum TTYForwardError: LocalizedError { case executableNotFound case notExecutable diff --git a/VibeTunnel/Core/Services/TerminalManager.swift b/VibeTunnel/Core/Services/TerminalManager.swift index 26947f5c..972885f8 100644 --- a/VibeTunnel/Core/Services/TerminalManager.swift +++ b/VibeTunnel/Core/Services/TerminalManager.swift @@ -2,14 +2,21 @@ import Combine import Foundation import Logging -/// Holds pipes for a terminal session +/// Holds pipes for a terminal session. +/// +/// Encapsulates the standard I/O pipes used for communicating +/// with a terminal process. private struct SessionPipes { let stdin: Pipe let stdout: Pipe let stderr: Pipe } -/// Manages terminal sessions and command execution +/// Manages terminal sessions and command execution. +/// +/// An actor that handles the lifecycle of terminal sessions, including +/// process creation, I/O handling, and command execution. Provides +/// thread-safe management of multiple concurrent terminal sessions. actor TerminalManager { private var sessions: [UUID: TunnelSession] = [:] private var processes: [UUID: Process] = [:] @@ -149,7 +156,10 @@ actor TerminalManager { } } -/// Errors that can occur in tunnel operations +/// Errors that can occur in tunnel operations. +/// +/// Represents various failure modes in terminal session management +/// including missing sessions, execution failures, and timeouts. enum TunnelError: LocalizedError { case sessionNotFound case commandExecutionFailed(String) diff --git a/VibeTunnel/Core/Services/TunnelClient.swift b/VibeTunnel/Core/Services/TunnelClient.swift index 51536995..c7102fcc 100644 --- a/VibeTunnel/Core/Services/TunnelClient.swift +++ b/VibeTunnel/Core/Services/TunnelClient.swift @@ -2,7 +2,10 @@ import Foundation import HTTPTypes import Logging -/// WebSocket message types for terminal communication +/// WebSocket message types for terminal communication. +/// +/// Defines the different types of messages that can be exchanged +/// between the client and server over WebSocket connections. public enum WSMessageType: String, Codable { case connect case command @@ -13,7 +16,10 @@ public enum WSMessageType: String, Codable { case close } -/// WebSocket message structure +/// WebSocket message structure. +/// +/// Encapsulates data sent over WebSocket connections for terminal +/// communication, including message type, session information, and payload. public struct WSMessage: Codable { public let type: WSMessageType public let sessionId: String? @@ -28,7 +34,11 @@ public struct WSMessage: Codable { } } -/// Client SDK for interacting with the VibeTunnel server +/// Client SDK for interacting with the VibeTunnel server. +/// +/// Provides a high-level interface for creating and managing terminal sessions +/// through the VibeTunnel HTTP API. Handles authentication, request/response +/// serialization, and error handling for all server operations. public class TunnelClient { private let baseURL: URL private let apiKey: String @@ -220,7 +230,11 @@ public class TunnelClient { } } -/// WebSocket client for real-time terminal communication +/// WebSocket client for real-time terminal communication. +/// +/// Provides WebSocket connectivity for streaming terminal I/O and +/// receiving real-time updates from terminal sessions. Handles +/// authentication, message encoding/decoding, and connection lifecycle. public final class TunnelWebSocketClient: NSObject, @unchecked Sendable { private let url: URL private let apiKey: String @@ -340,6 +354,10 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate { // MARK: - Errors +/// Errors that can occur when using the TunnelClient. +/// +/// Represents various failure modes including network errors, +/// server errors, and data decoding issues. public enum TunnelClientError: LocalizedError, Equatable { case invalidResponse case httpError(statusCode: Int) diff --git a/VibeTunnel/Core/Services/TunnelServer.swift b/VibeTunnel/Core/Services/TunnelServer.swift index f940bc3a..ebf24bb1 100644 --- a/VibeTunnel/Core/Services/TunnelServer.swift +++ b/VibeTunnel/Core/Services/TunnelServer.swift @@ -96,7 +96,13 @@ struct StreamResponse: Codable { let streamPath: String } -/// HTTP server that provides API endpoints for terminal session management +/// HTTP server that provides API endpoints for terminal session management. +/// +/// `TunnelServer` implements a Hummingbird-based HTTP server that bridges web clients +/// with the tty-fwd terminal multiplexer. It provides RESTful APIs for session management, +/// command execution, and filesystem browsing, along with WebSocket support for real-time +/// terminal streaming. The server serves the web UI as static files and handles all +/// terminal-related operations through a local HTTP interface. /// /// This server runs locally and provides: /// - Session creation, listing, and management @@ -136,10 +142,8 @@ public final class TunnelServer { // 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)) - } + // Add lazy basic auth middleware - defers password loading until needed + router.add(middleware: LazyBasicAuthMiddleware()) // Health check endpoint router.get("/api/health") { _, _ async -> Response in diff --git a/VibeTunnel/Core/Utilities/NetworkUtility.swift b/VibeTunnel/Core/Utilities/NetworkUtility.swift index 537e5fd9..97d494f8 100644 --- a/VibeTunnel/Core/Utilities/NetworkUtility.swift +++ b/VibeTunnel/Core/Utilities/NetworkUtility.swift @@ -1,7 +1,11 @@ import Foundation import Network -/// Utility for network-related operations +/// Utility for network-related operations. +/// +/// Provides helper functions for network interface discovery and IP address resolution. +/// Primarily used to determine the local machine's network addresses for display +/// in the dashboard settings. enum NetworkUtility { /// Get the primary IPv4 address of the local machine static func getLocalIPAddress() -> String? { diff --git a/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift b/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift index b28431f9..68ecdb68 100644 --- a/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift +++ b/VibeTunnel/Core/Utilities/WindowCenteringHelper.swift @@ -1,6 +1,10 @@ import AppKit -/// Helper class for consistent window centering across the application +/// Helper class for consistent window centering across the application. +/// +/// Provides utility methods for positioning windows on screen, including +/// centering on the active display and moving windows off-screen when needed. +/// Used throughout VibeTunnel to ensure consistent window placement behavior. enum WindowCenteringHelper { /// Centers a window on the active screen (where the mouse cursor is located) /// - Parameter window: The NSWindow to center diff --git a/VibeTunnel/Presentation/Utilities/View+Cursor.swift b/VibeTunnel/Presentation/Utilities/View+Cursor.swift index 1dd2a2a9..78eaed5d 100644 --- a/VibeTunnel/Presentation/Utilities/View+Cursor.swift +++ b/VibeTunnel/Presentation/Utilities/View+Cursor.swift @@ -1,5 +1,6 @@ import SwiftUI +/// Extensions for SwiftUI View to handle cursor and press events. extension View { func pressEvents(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { modifier(PressEventModifier(onPress: onPress, onRelease: onRelease)) @@ -11,6 +12,9 @@ extension View { } /// View modifier for handling press events on buttons. +/// +/// Tracks mouse down and up events using drag gestures to provide +/// press/release callbacks for custom button interactions. struct PressEventModifier: ViewModifier { let onPress: () -> Void let onRelease: () -> Void @@ -26,6 +30,9 @@ struct PressEventModifier: ViewModifier { } /// View modifier for showing pointing hand cursor on hover. +/// +/// Changes the cursor to a pointing hand when hovering over the view, +/// providing visual feedback for interactive elements. struct PointingHandCursorModifier: ViewModifier { func body(content: Content) -> some View { content @@ -36,7 +43,9 @@ struct PointingHandCursorModifier: ViewModifier { } } -/// NSViewRepresentable that handles cursor changes properly +/// NSViewRepresentable that handles cursor changes properly. +/// +/// Bridges AppKit's cursor tracking to SwiftUI views. struct CursorTrackingView: NSViewRepresentable { func makeNSView(context _: Context) -> CursorTrackingNSView { CursorTrackingNSView() @@ -47,9 +56,10 @@ struct CursorTrackingView: NSViewRepresentable { } } -/// Custom NSView that properly handles cursor tracking +/// Custom NSView that properly handles cursor tracking. /// /// This view ensures the pointing hand cursor is displayed when hovering over interactive elements +/// by managing cursor rectangles and invalidating them when the view hierarchy changes. class CursorTrackingNSView: NSView { override func resetCursorRects() { super.resetCursorRects() diff --git a/VibeTunnel/Presentation/Views/AboutView.swift b/VibeTunnel/Presentation/Views/AboutView.swift index 90263949..b8abfb28 100644 --- a/VibeTunnel/Presentation/Views/AboutView.swift +++ b/VibeTunnel/Presentation/Views/AboutView.swift @@ -1,6 +1,37 @@ import AppKit import SwiftUI +// MARK: - Credit Link Component + +/// Credit link component for individual contributors. +/// +/// This component displays a contributor's handle as a clickable link +/// that opens their website when clicked. +struct CreditLink: View { + let name: String + let url: String + @State private var isHovering = false + + var body: some View { + Button(action: { + 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 + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } +} + /// About view displaying application information, version details, and credits. /// /// This view provides information about VibeTunnel including version numbers, @@ -70,10 +101,35 @@ struct AboutView: View { } private var copyrightSection: some View { - Text("© 2025 VibeTunnel Team • MIT Licensed") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.bottom, 32) + VStack(spacing: 8) { + // Credits + VStack(spacing: 4) { + Text("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") + } + } + + Text("© 2025 • MIT Licensed") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.bottom, 32) } } diff --git a/VibeTunnel/Presentation/Views/MenuBarView.swift b/VibeTunnel/Presentation/Views/MenuBarView.swift index 8ddf6934..db596816 100644 --- a/VibeTunnel/Presentation/Views/MenuBarView.swift +++ b/VibeTunnel/Presentation/Views/MenuBarView.swift @@ -1,6 +1,10 @@ import SwiftUI -/// Main menu bar view displaying session status and app controls +/// Main menu bar view displaying session status and app controls. +/// +/// Appears in the macOS menu bar and provides quick access to VibeTunnel's +/// key features including server status, dashboard access, session monitoring, +/// and application preferences. Updates in real-time to reflect server state. struct MenuBarView: View { @Environment(SessionMonitor.self) var sessionMonitor @@ -91,8 +95,8 @@ struct MenuBarView: View { // Version (non-interactive) HStack { - Color.clear - .frame(width: 16, height: 16) // Matches system icon size + Image(systemName: "circle") + .opacity(0) // Invisible placeholder to match icon spacing Text("Version \(appVersion)") .foregroundColor(.secondary) } diff --git a/VibeTunnel/Presentation/Views/ServerConsoleView.swift b/VibeTunnel/Presentation/Views/ServerConsoleView.swift index b8c704a1..59dd04c3 100644 --- a/VibeTunnel/Presentation/Views/ServerConsoleView.swift +++ b/VibeTunnel/Presentation/Views/ServerConsoleView.swift @@ -1,7 +1,11 @@ import Observation import SwiftUI -/// View for displaying server console logs +/// View for displaying server console logs. +/// +/// Provides a real-time console interface for monitoring server output with +/// filtering capabilities, auto-scroll functionality, and color-coded log levels. +/// Supports both Rust and Hummingbird server implementations. struct ServerConsoleView: View { @State private var viewModel = ServerConsoleViewModel() @State private var autoScroll = true @@ -142,7 +146,11 @@ struct ServerLogEntryView: View { } } -/// View model for the server console +/// View model for the server console. +/// +/// Manages the collection and filtering of server log entries, +/// subscribing to the server's log stream and maintaining a +/// bounded collection of recent logs. @MainActor @Observable class ServerConsoleViewModel { diff --git a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 70b6789e..9c31be3c 100644 --- a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -154,6 +154,9 @@ struct DashboardSettingsView: View { password = "" confirmPassword = "" + // Clear cached password in LazyBasicAuthMiddleware + LazyBasicAuthMiddleware.clearCache() + // When password is set for the first time, automatically switch to network mode if accessMode == .localhost { accessModeString = DashboardAccessMode.network.rawValue @@ -302,6 +305,8 @@ private struct SecuritySection: View { _ = dashboardKeychain.deletePassword() showPasswordFields = false passwordSaved = false + // Clear cached password in LazyBasicAuthMiddleware + LazyBasicAuthMiddleware.clearCache() } } diff --git a/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift b/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift index c2ebb103..37b4a656 100644 --- a/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift +++ b/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift @@ -1,6 +1,9 @@ import Foundation -/// Represents the available tabs in the Settings window +/// Represents the available tabs in the Settings window. +/// +/// Each tab corresponds to a different configuration area of VibeTunnel, +/// with associated display names and SF Symbol icons for the tab bar. enum SettingsTab: String, CaseIterable { case general case dashboard diff --git a/VibeTunnel/Presentation/Views/SettingsView.swift b/VibeTunnel/Presentation/Views/SettingsView.swift index 6c5c016a..ae085127 100644 --- a/VibeTunnel/Presentation/Views/SettingsView.swift +++ b/VibeTunnel/Presentation/Views/SettingsView.swift @@ -1,6 +1,10 @@ import SwiftUI -/// Main settings window with tabbed interface +/// Main settings window with tabbed interface. +/// +/// Provides a macOS-style preferences window with multiple tabs for different +/// configuration aspects of VibeTunnel. Dynamically adjusts window size based +/// on the selected tab and conditionally shows debug settings when enabled. struct SettingsView: View { @State private var selectedTab: SettingsTab = .general @State private var contentSize: CGSize = .zero @@ -51,7 +55,6 @@ struct SettingsView: View { .tag(SettingsTab.about) } .frame(width: contentSize.width, height: contentSize.height) - .animatedWindowSizing(size: contentSize) .onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in if let tab = notification.object as? SettingsTab { selectedTab = tab diff --git a/VibeTunnel/Presentation/Views/SharedComponents.swift b/VibeTunnel/Presentation/Views/SharedComponents.swift new file mode 100644 index 00000000..78d51d14 --- /dev/null +++ b/VibeTunnel/Presentation/Views/SharedComponents.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - Credit Link Component + +/// Credit link component for individual contributors. +/// +/// This component displays a contributor's handle as a clickable link +/// that opens their website when clicked. +struct CreditLink: View { + let name: String + let url: String + @State private var isHovering = false + + var body: some View { + Button(action: { + 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 + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } +} diff --git a/VibeTunnel/Presentation/Views/WelcomeView.swift b/VibeTunnel/Presentation/Views/WelcomeView.swift index f117503e..6df8fbeb 100644 --- a/VibeTunnel/Presentation/Views/WelcomeView.swift +++ b/VibeTunnel/Presentation/Views/WelcomeView.swift @@ -1,5 +1,10 @@ import SwiftUI +/// Welcome onboarding view for first-time users. +/// +/// Presents a multi-page onboarding experience that introduces VibeTunnel's features, +/// guides through CLI installation, and explains dashboard security best practices. +/// The view tracks completion state to ensure it's only shown once. struct WelcomeView: View { @State private var currentPage = 0 @Environment(\.dismiss) @@ -101,7 +106,8 @@ struct WelcomeView: View { // MARK: - Welcome Page -struct WelcomePageView: View { +/// First page of the welcome flow introducing VibeTunnel. +private struct WelcomePageView: View { var body: some View { VStack(spacing: 40) { Spacer() @@ -117,7 +123,7 @@ struct WelcomePageView: View { .font(.largeTitle) .fontWeight(.semibold) - Text("Remote control terminals from any device through a secure tunnel.") + Text("Turn any browser into your terminal. Command your agents on the go.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -141,7 +147,8 @@ struct WelcomePageView: View { // MARK: - VT Command Page -struct VTCommandPageView: View { +/// Second page explaining the VT command-line tool and installation. +private struct VTCommandPageView: View { var cliInstaller: CLIInstaller var body: some View { @@ -225,7 +232,8 @@ struct VTCommandPageView: View { // MARK: - Protect Dashboard Page -struct ProtectDashboardPageView: View { +/// Third page explaining dashboard security and access protection. +private struct ProtectDashboardPageView: View { @State private var password = "" @State private var confirmPassword = "" @State private var showError = false @@ -352,7 +360,8 @@ struct ProtectDashboardPageView: View { // MARK: - Access Dashboard Page -struct AccessDashboardPageView: View { +/// Fourth page showing how to access the dashboard and ngrok integration. +private struct AccessDashboardPageView: View { @AppStorage("ngrokEnabled") private var ngrokEnabled = false @AppStorage("serverPort") @@ -457,33 +466,6 @@ 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: { - 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 - withAnimation(.easeInOut(duration: 0.2)) { - isHovering = hovering - } - } - } -} - // MARK: - Preview struct WelcomeView_Previews: PreviewProvider { diff --git a/VibeTunnel/Utilities/ApplicationMover.swift b/VibeTunnel/Utilities/ApplicationMover.swift index 29234d14..2c00334e 100644 --- a/VibeTunnel/Utilities/ApplicationMover.swift +++ b/VibeTunnel/Utilities/ApplicationMover.swift @@ -321,7 +321,7 @@ final class ApplicationMover { Task { @MainActor in do { let configuration = NSWorkspace.OpenConfiguration() - _ = try await workspace.openApplication(at: appURL, configuration: configuration) + try await workspace.openApplication(at: appURL, configuration: configuration) logger.info("Launched app from Applications, quitting current instance") // Quit current instance after a short delay to ensure the new one starts diff --git a/VibeTunnel/Utilities/SettingsOpener.swift b/VibeTunnel/Utilities/SettingsOpener.swift index ece32151..c1ff2757 100644 --- a/VibeTunnel/Utilities/SettingsOpener.swift +++ b/VibeTunnel/Utilities/SettingsOpener.swift @@ -2,7 +2,11 @@ import AppKit import Foundation import SwiftUI -/// Helper to open the Settings window programmatically when SettingsLink cannot be used +/// Helper to open the Settings window programmatically when SettingsLink cannot be used. +/// +/// Provides workarounds for opening the Settings window in menu bar apps where +/// SwiftUI's SettingsLink may not function correctly. Uses multiple strategies +/// including menu item triggering and window manipulation to ensure reliable behavior. @MainActor enum SettingsOpener { /// SwiftUI's hardcoded settings window identifier @@ -140,8 +144,11 @@ enum SettingsOpener { // MARK: - Hidden Window View -/// A hidden window view that enables Settings to work in MenuBarExtra-only apps -/// This is a workaround for FB10184971 +/// A hidden window view that enables Settings to work in MenuBarExtra-only apps. +/// +/// This is a workaround for FB10184971 where SettingsLink doesn't function +/// properly in menu bar apps. Creates an invisible window that can receive +/// the openSettings environment action. struct HiddenWindowView: View { @Environment(\.openSettings) private var openSettings diff --git a/VibeTunnel/Utilities/TerminalLauncher.swift b/VibeTunnel/Utilities/TerminalLauncher.swift index d87f1e20..f94739b8 100644 --- a/VibeTunnel/Utilities/TerminalLauncher.swift +++ b/VibeTunnel/Utilities/TerminalLauncher.swift @@ -2,6 +2,10 @@ import AppKit import Foundation import SwiftUI +/// Supported terminal applications. +/// +/// Represents terminal emulators that VibeTunnel can launch +/// with commands, including detection of installed terminals. enum Terminal: String, CaseIterable { case terminal = "Terminal" case iTerm2 = "iTerm2" @@ -37,10 +41,14 @@ enum Terminal: String, CaseIterable { } static var installed: [Self] { - Self.allCases.filter(\.isInstalled) + allCases.filter(\.isInstalled) } } +/// Errors that can occur when launching terminal commands. +/// +/// Represents failures during terminal application launch, +/// including permission issues and missing applications. enum TerminalLauncherError: LocalizedError { case terminalNotFound case appleScriptPermissionDenied @@ -58,6 +66,11 @@ enum TerminalLauncherError: LocalizedError { } } +/// Manages launching terminal commands in the user's preferred terminal. +/// +/// Handles terminal application detection, preference management, +/// and command execution through AppleScript or direct process launching. +/// Supports Terminal, iTerm2, and Ghostty with automatic fallback. @MainActor final class TerminalLauncher { static let shared = TerminalLauncher() diff --git a/VibeTunnel/Utilities/WelcomeWindowController.swift b/VibeTunnel/Utilities/WelcomeWindowController.swift index cbbf4bdb..ac434e69 100644 --- a/VibeTunnel/Utilities/WelcomeWindowController.swift +++ b/VibeTunnel/Utilities/WelcomeWindowController.swift @@ -1,7 +1,11 @@ import AppKit import SwiftUI -/// Handles the presentation of the welcome screen window +/// Handles the presentation of the welcome screen window. +/// +/// Manages the lifecycle and presentation of the onboarding welcome window, +/// including window configuration, positioning, and notification-based showing. +/// Configured as a floating panel with transparent titlebar for modern appearance. @MainActor final class WelcomeWindowController: NSWindowController { static let shared = WelcomeWindowController() diff --git a/VibeTunnel/Utilities/WindowSizeAnimator.swift b/VibeTunnel/Utilities/WindowSizeAnimator.swift deleted file mode 100644 index eb7bc635..00000000 --- a/VibeTunnel/Utilities/WindowSizeAnimator.swift +++ /dev/null @@ -1,108 +0,0 @@ -import AppKit -import Observation -import SwiftUI - -/// A custom window size animator that works with SwiftUI Settings windows -@MainActor -@Observable -final class WindowSizeAnimator { - static let shared = WindowSizeAnimator() - - private weak var window: NSWindow? - private var animator: NSViewAnimation? - - private init() {} - - /// Find and store reference to the settings window - func captureSettingsWindow() { - // Try multiple strategies to find the window - if let window = NSApp.windows.first(where: { window in - // Check if it's a settings-like window - window.isVisible && - window.level == .normal && - !window.isKind(of: NSPanel.self) && - window.canBecomeKey && - (window.title.isEmpty || window.title.contains("VibeTunnel") || - window.title.lowercased().contains("settings") || - window.title.lowercased().contains("preferences") - ) - }) { - self.window = window - // Disable user resizing - window.styleMask.remove(.resizable) - } - } - - /// Animate window to new size using NSViewAnimation - func animateWindowSize(to newSize: CGSize, duration: TimeInterval = 0.25) { - guard let window else { - // Try to capture window if we haven't yet - captureSettingsWindow() - guard self.window != nil else { return } - animateWindowSize(to: newSize, duration: duration) - return - } - - // Cancel any existing animation - animator?.stop() - - // Calculate new frame keeping top-left corner fixed - var newFrame = window.frame - let heightDifference = newSize.height - newFrame.height - newFrame.size = newSize - newFrame.origin.y -= heightDifference - - // Create animation dictionary - let windowDict: [NSViewAnimation.Key: Any] = [ - .target: window, - .startFrame: window.frame, - .endFrame: newFrame - ] - - // Create and configure animation - let animation = NSViewAnimation(viewAnimations: [windowDict]) - animation.animationBlockingMode = .nonblocking - animation.animationCurve = .easeInOut - animation.duration = duration - - // Store animator reference - self.animator = animation - - // Start animation - animation.start() - } -} - -/// A view modifier that captures the window and enables animated resizing -struct AnimatedWindowSizing: ViewModifier { - let size: CGSize - @State private var animator = WindowSizeAnimator.shared - - func body(content: Content) -> some View { - content - .onAppear { - // Capture window after a delay to ensure it's created - Task { - try? await Task.sleep(for: .milliseconds(100)) - await MainActor.run { - animator.captureSettingsWindow() - // Set initial size without animation - if let window = NSApp.keyWindow { - var frame = window.frame - frame.size = size - window.setFrame(frame, display: true) - } - } - } - } - .onChange(of: size) { _, newSize in - animator.animateWindowSize(to: newSize) - } - } -} - -extension View { - func animatedWindowSizing(size: CGSize) -> some View { - modifier(AnimatedWindowSizing(size: size)) - } -} diff --git a/assets/menu.png b/assets/menu.png deleted file mode 100644 index 9b51f386..00000000 Binary files a/assets/menu.png and /dev/null differ diff --git a/assets/menu.psd b/assets/menu.psd new file mode 100644 index 00000000..8a5d802e Binary files /dev/null and b/assets/menu.psd differ diff --git a/tty-fwd/src/sessions.rs b/tty-fwd/src/sessions.rs index bd89ae52..74ad4cbc 100644 --- a/tty-fwd/src/sessions.rs +++ b/tty-fwd/src/sessions.rs @@ -155,11 +155,8 @@ fn write_to_pipe_with_timeout( match poll_result { -1 => { - let errno = unsafe { *libc::__error() }; - return Err(anyhow!( - "Poll failed: {}", - std::io::Error::from_raw_os_error(errno) - )); + let errno = std::io::Error::last_os_error(); + return Err(anyhow!("Poll failed: {}", errno)); } 0 => { return Err(anyhow!("Write operation timed out after {:?}", timeout));