Merge branch 'ci-setup' into main

This commit is contained in:
Peter Steinberger 2025-06-17 01:52:16 +02:00
commit eac1d8251a
41 changed files with 663 additions and 240 deletions

View file

@ -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:

89
KEYCHAIN_OPTIMIZATION.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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<Context: RequestContext>: RouterMiddleware {
let password: String
let realm: String

View file

@ -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

View file

@ -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()

View file

@ -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
}

View file

@ -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?

View file

@ -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<Context: RequestContext>: 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
}
}

View file

@ -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"

View file

@ -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<ServerLogEntry, Never>()
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]

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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<ServerLogEntry, Never> { 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

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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? {

View file

@ -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

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -154,6 +154,9 @@ struct DashboardSettingsView: View {
password = ""
confirmPassword = ""
// Clear cached password in LazyBasicAuthMiddleware
LazyBasicAuthMiddleware<BasicRequestContext>.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<BasicRequestContext>.clearCache()
}
}

View file

@ -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

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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))
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

BIN
assets/menu.psd Normal file

Binary file not shown.

View file

@ -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));