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 target: aarch64-apple-darwin
name: macOS ARM64 name: macOS ARM64
binary-name: tty-fwd 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 }} runs-on: ${{ matrix.os }}
steps: 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 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 { enum DashboardAccessMode: String, CaseIterable {
case localhost case localhost
case network case network

View file

@ -1,6 +1,10 @@
import Foundation 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 struct TunnelSession: Identifiable, Codable, Sendable {
public let id: UUID public let id: UUID
public let createdAt: Date 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 struct CreateSessionRequest: Codable, Sendable {
public let workingDirectory: String? public let workingDirectory: String?
public let environment: [String: 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 struct CreateSessionResponse: Codable, Sendable {
public let sessionId: String public let sessionId: String
public let createdAt: Date 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 struct CommandRequest: Codable, Sendable {
public let sessionId: String public let sessionId: String
public let command: 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 struct CommandResponse: Codable, Sendable {
public let sessionId: String public let sessionId: String
public let output: 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 struct SessionInfo: Codable, Sendable {
public let id: String public let id: String
public let createdAt: Date 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 struct ListSessionsResponse: Codable, Sendable {
public let sessions: [SessionInfo] public let sessions: [SessionInfo]
@ -110,7 +129,10 @@ public struct ListSessionsResponse: Codable, Sendable {
// MARK: - Extensions for TunnelClient // MARK: - Extensions for TunnelClient
extension TunnelSession { 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 struct ClientInfo: Codable, Sendable {
public let hostname: String public let hostname: String
public let username: 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 struct CreateRequest: Codable, Sendable {
public let clientInfo: ClientInfo? 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 struct CreateResponse: Codable, Sendable {
public let id: String public let id: String
public let session: TunnelSession 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 struct ExecuteCommandRequest: Codable, Sendable {
public let sessionId: String public let sessionId: String
public let command: 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 struct ExecuteCommandResponse: Codable, Sendable {
public let exitCode: Int32 public let exitCode: Int32
public let stdout: String 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 struct HealthResponse: Codable, Sendable {
public let status: String public let status: String
public let timestamp: Date 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 struct ListResponse: Codable, Sendable {
public let sessions: [TunnelSession] 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 struct ErrorResponse: Codable, Sendable {
public let error: String public let error: String
public let code: String? public let code: String?

View file

@ -4,7 +4,12 @@ import Hummingbird
import HummingbirdCore import HummingbirdCore
import NIOCore 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 { struct BasicAuthMiddleware<Context: RequestContext>: RouterMiddleware {
let password: String let password: String
let realm: String let realm: String

View file

@ -1,11 +1,20 @@
import Foundation import Foundation
import Logging 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/ /// Format specification: https://docs.asciinema.org/manual/asciicast/v2/
struct CastFileGenerator { struct CastFileGenerator {
private let logger = Logger(label: "VibeTunnel.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 { struct CastHeader: Codable {
let version: Int = 2 let version: Int = 2
let width: Int 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 { struct CastEvent {
let time: TimeInterval let time: TimeInterval
let eventType: String let eventType: String

View file

@ -2,7 +2,11 @@ import Foundation
import os import os
import Security 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 @MainActor
final class DashboardKeychain { final class DashboardKeychain {
static let shared = DashboardKeychain() static let shared = DashboardKeychain()

View file

@ -1,12 +1,19 @@
import Foundation import Foundation
import HTTPTypes 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 { public protocol HTTPClientProtocol {
func data(for request: HTTPRequest, body: Data?) async throws -> (Data, HTTPResponse) 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 { public final class HTTPClient: HTTPClientProtocol {
private let session: URLSession private let session: URLSession
@ -29,6 +36,7 @@ public final class HTTPClient: HTTPClientProtocol {
} }
} }
/// Errors that can occur during HTTP client operations.
enum HTTPClientError: Error { enum HTTPClientError: Error {
case invalidResponse case invalidResponse
} }

View file

@ -3,7 +3,12 @@ import Foundation
import Hummingbird import Hummingbird
import OSLog 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 @MainActor
final class HummingbirdServer: ServerProtocol { final class HummingbirdServer: ServerProtocol {
private var tunnelServer: TunnelServer? 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 Observation
import os 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 { enum NgrokError: LocalizedError {
case notInstalled case notInstalled
case authTokenMissing 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 { struct NgrokTunnelStatus: Codable {
let publicUrl: String let publicUrl: String
let metrics: TunnelMetrics let metrics: TunnelMetrics
let startedAt: Date let startedAt: Date
/// Traffic metrics for the ngrok tunnel.
///
/// Tracks connection count and bandwidth usage.
struct TunnelMetrics: Codable { struct TunnelMetrics: Codable {
let connectionsCount: Int let connectionsCount: Int
let bytesIn: Int64 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 { protocol NgrokTunnelProtocol {
func start(port: Int) async throws -> String func start(port: Int) async throws -> String
func stop() async throws func stop() async throws
@ -47,10 +59,12 @@ protocol NgrokTunnelProtocol {
func isRunning() async -> Bool 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 /// `NgrokService` provides a high-level interface for creating and managing ngrok tunnels
/// to expose local services to the internet /// 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 @Observable
@MainActor @MainActor
final class NgrokService: NgrokTunnelProtocol { 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 { struct AsyncLineSequence: AsyncSequence {
typealias Element = String typealias Element = String
@ -345,7 +362,10 @@ struct AsyncLineSequence: AsyncSequence {
// MARK: - Keychain Helper // 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 enum KeychainHelper {
private static let service = "sh.vibetunnel.vibetunnel" private static let service = "sh.vibetunnel.vibetunnel"
private static let account = "ngrok-auth-token" private static let account = "ngrok-auth-token"

View file

@ -2,14 +2,22 @@ import Combine
import Foundation import Foundation
import OSLog 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 { enum ServerTaskContext {
@TaskLocal static var taskName: String? @TaskLocal static var taskName: String?
@TaskLocal static var serverType: ServerMode? @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 @MainActor
final class RustServer: ServerProtocol { final class RustServer: ServerProtocol {
private var process: Process? private var process: Process?
@ -22,7 +30,10 @@ final class RustServer: ServerProtocol {
private let logSubject = PassthroughSubject<ServerLogEntry, Never>() private let logSubject = PassthroughSubject<ServerLogEntry, Never>()
private let processQueue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer", qos: .userInitiated) 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 actor ProcessHandler {
private let queue = DispatchQueue( private let queue = DispatchQueue(
label: "com.steipete.VibeTunnel.RustServer.ProcessHandler", label: "com.steipete.VibeTunnel.RustServer.ProcessHandler",
@ -148,13 +159,22 @@ final class RustServer: ServerProtocol {
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)" var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled // Add password flag if password protection is enabled
if let password = DashboardKeychain.shared.getPassword() { // Only check if password exists, don't retrieve it yet
// Escape the password for shell if UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") && DashboardKeychain.shared.hasPassword() {
let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"") // Defer actual password retrieval until first authenticated request
.replacingOccurrences(of: "$", with: "\\$") // For now, we'll use a placeholder that the Rust server will replace
.replacingOccurrences(of: "`", with: "\\`") // when it needs to authenticate
.replacingOccurrences(of: "\\", with: "\\\\") logger.info("Password protection enabled, deferring keychain access")
ttyFwdCommand += " --password \"\(escapedPassword)\"" // 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] process.arguments = ["-l", "-c", ttyFwdCommand]

View file

@ -4,7 +4,12 @@ import Observation
import OSLog import OSLog
import SwiftUI 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 @MainActor
@Observable @Observable
class ServerManager { class ServerManager {
@ -43,6 +48,7 @@ class ServerManager {
private(set) var currentServer: ServerProtocol? private(set) var currentServer: ServerProtocol?
private(set) var isRunning = false private(set) var isRunning = false
private(set) var isSwitching = false private(set) var isSwitching = false
private(set) var isRestarting = false
private(set) var lastError: Error? private(set) var lastError: Error?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager") private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager")
@ -188,11 +194,25 @@ class ServerManager {
)) ))
// Update ServerMonitor for compatibility // 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 /// Restart the current server
func restart() async { 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 stop()
await start() await start()
} }

View file

@ -1,8 +1,11 @@
import Foundation import Foundation
import Observation 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 /// 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 @MainActor
@Observable @Observable
public final class ServerMonitor { public final class ServerMonitor {
@ -25,11 +28,7 @@ public final class ServerMonitor {
private weak var server: TunnelServer? private weak var server: TunnelServer?
/// Internal state tracking /// Internal state tracking
@ObservationIgnored public var isServerRunning = false { public var isServerRunning = false
didSet {
// Notify observers when state changes
}
}
private init() { private init() {
// Sync initial state with ServerManager // Sync initial state with ServerManager
@ -53,7 +52,9 @@ public final class ServerMonitor {
/// Syncs state with ServerManager /// Syncs state with ServerManager
private func syncWithServerManager() async { 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 /// Starts the server if not already running
@ -72,7 +73,9 @@ public final class ServerMonitor {
/// Restarts the server /// Restarts the server
public func restartServer() async throws { public func restartServer() async throws {
// During restart, we maintain the running state to prevent UI flicker
await ServerManager.shared.restart() await ServerManager.shared.restart()
// Sync after restart completes
await syncWithServerManager() await syncWithServerManager()
} }

View file

@ -1,7 +1,11 @@
import Combine import Combine
import Foundation 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 @MainActor
protocol ServerProtocol: AnyObject { protocol ServerProtocol: AnyObject {
/// Current running state of the server /// Current running state of the server
@ -26,7 +30,11 @@ protocol ServerProtocol: AnyObject {
var logPublisher: AnyPublisher<ServerLogEntry, Never> { get } 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 { enum ServerMode: String, CaseIterable {
case hummingbird case hummingbird
case rust 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 { struct ServerLogEntry {
/// Severity level of the log entry.
enum Level { enum Level {
case debug case debug
case info case info

View file

@ -1,7 +1,11 @@
import Foundation import Foundation
import Observation 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 @MainActor
@Observable @Observable
class SessionMonitor { class SessionMonitor {
@ -15,7 +19,10 @@ class SessionMonitor {
private let refreshInterval: TimeInterval = 5.0 // Check every 5 seconds private let refreshInterval: TimeInterval = 5.0 // Check every 5 seconds
private var serverPort: Int 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 { struct SessionInfo: Codable {
let cmdline: [String] let cmdline: [String]
let cwd: String let cwd: String

View file

@ -4,7 +4,11 @@ import os.log
import Sparkle import Sparkle
import UserNotifications 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, *) @available(macOS 10.15, *)
@MainActor @MainActor
public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate { public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {

View file

@ -1,7 +1,11 @@
import Foundation import Foundation
import os.log 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 @MainActor
final class TTYForwardManager { final class TTYForwardManager {
static let shared = 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 { enum TTYForwardError: LocalizedError {
case executableNotFound case executableNotFound
case notExecutable case notExecutable

View file

@ -2,14 +2,21 @@ import Combine
import Foundation import Foundation
import Logging 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 { private struct SessionPipes {
let stdin: Pipe let stdin: Pipe
let stdout: Pipe let stdout: Pipe
let stderr: 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 { actor TerminalManager {
private var sessions: [UUID: TunnelSession] = [:] private var sessions: [UUID: TunnelSession] = [:]
private var processes: [UUID: Process] = [:] 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 { enum TunnelError: LocalizedError {
case sessionNotFound case sessionNotFound
case commandExecutionFailed(String) case commandExecutionFailed(String)

View file

@ -2,7 +2,10 @@ import Foundation
import HTTPTypes import HTTPTypes
import Logging 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 { public enum WSMessageType: String, Codable {
case connect case connect
case command case command
@ -13,7 +16,10 @@ public enum WSMessageType: String, Codable {
case close 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 struct WSMessage: Codable {
public let type: WSMessageType public let type: WSMessageType
public let sessionId: String? 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 { public class TunnelClient {
private let baseURL: URL private let baseURL: URL
private let apiKey: String 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 { public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
private let url: URL private let url: URL
private let apiKey: String private let apiKey: String
@ -340,6 +354,10 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
// MARK: - Errors // 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 { public enum TunnelClientError: LocalizedError, Equatable {
case invalidResponse case invalidResponse
case httpError(statusCode: Int) case httpError(statusCode: Int)

View file

@ -96,7 +96,13 @@ struct StreamResponse: Codable {
let streamPath: String 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: /// This server runs locally and provides:
/// - Session creation, listing, and management /// - Session creation, listing, and management
@ -136,10 +142,8 @@ public final class TunnelServer {
// Add middleware // Add middleware
router.add(middleware: LogRequestsMiddleware(.info)) router.add(middleware: LogRequestsMiddleware(.info))
// Add basic auth middleware if password is set // Add lazy basic auth middleware - defers password loading until needed
if let password = DashboardKeychain.shared.getPassword() { router.add(middleware: LazyBasicAuthMiddleware())
router.add(middleware: BasicAuthMiddleware(password: password))
}
// Health check endpoint // Health check endpoint
router.get("/api/health") { _, _ async -> Response in router.get("/api/health") { _, _ async -> Response in

View file

@ -1,7 +1,11 @@
import Foundation import Foundation
import Network 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 { enum NetworkUtility {
/// Get the primary IPv4 address of the local machine /// Get the primary IPv4 address of the local machine
static func getLocalIPAddress() -> String? { static func getLocalIPAddress() -> String? {

View file

@ -1,6 +1,10 @@
import AppKit 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 { enum WindowCenteringHelper {
/// Centers a window on the active screen (where the mouse cursor is located) /// Centers a window on the active screen (where the mouse cursor is located)
/// - Parameter window: The NSWindow to center /// - Parameter window: The NSWindow to center

View file

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
/// Extensions for SwiftUI View to handle cursor and press events.
extension View { extension View {
func pressEvents(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { func pressEvents(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View {
modifier(PressEventModifier(onPress: onPress, onRelease: onRelease)) modifier(PressEventModifier(onPress: onPress, onRelease: onRelease))
@ -11,6 +12,9 @@ extension View {
} }
/// View modifier for handling press events on buttons. /// 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 { struct PressEventModifier: ViewModifier {
let onPress: () -> Void let onPress: () -> Void
let onRelease: () -> Void let onRelease: () -> Void
@ -26,6 +30,9 @@ struct PressEventModifier: ViewModifier {
} }
/// View modifier for showing pointing hand cursor on hover. /// 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 { struct PointingHandCursorModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content 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 { struct CursorTrackingView: NSViewRepresentable {
func makeNSView(context _: Context) -> CursorTrackingNSView { func makeNSView(context _: Context) -> CursorTrackingNSView {
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 /// 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 { class CursorTrackingNSView: NSView {
override func resetCursorRects() { override func resetCursorRects() {
super.resetCursorRects() super.resetCursorRects()

View file

@ -1,6 +1,37 @@
import AppKit import AppKit
import SwiftUI 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. /// About view displaying application information, version details, and credits.
/// ///
/// This view provides information about VibeTunnel including version numbers, /// This view provides information about VibeTunnel including version numbers,
@ -70,10 +101,35 @@ struct AboutView: View {
} }
private var copyrightSection: some View { private var copyrightSection: some View {
Text("© 2025 VibeTunnel Team • MIT Licensed") VStack(spacing: 8) {
.font(.footnote) // Credits
.foregroundStyle(.secondary) VStack(spacing: 4) {
.padding(.bottom, 32) 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 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 { struct MenuBarView: View {
@Environment(SessionMonitor.self) @Environment(SessionMonitor.self)
var sessionMonitor var sessionMonitor
@ -91,8 +95,8 @@ struct MenuBarView: View {
// Version (non-interactive) // Version (non-interactive)
HStack { HStack {
Color.clear Image(systemName: "circle")
.frame(width: 16, height: 16) // Matches system icon size .opacity(0) // Invisible placeholder to match icon spacing
Text("Version \(appVersion)") Text("Version \(appVersion)")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }

View file

@ -1,7 +1,11 @@
import Observation import Observation
import SwiftUI 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 { struct ServerConsoleView: View {
@State private var viewModel = ServerConsoleViewModel() @State private var viewModel = ServerConsoleViewModel()
@State private var autoScroll = true @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 @MainActor
@Observable @Observable
class ServerConsoleViewModel { class ServerConsoleViewModel {

View file

@ -154,6 +154,9 @@ struct DashboardSettingsView: View {
password = "" password = ""
confirmPassword = "" confirmPassword = ""
// Clear cached password in LazyBasicAuthMiddleware
LazyBasicAuthMiddleware<BasicRequestContext>.clearCache()
// When password is set for the first time, automatically switch to network mode // When password is set for the first time, automatically switch to network mode
if accessMode == .localhost { if accessMode == .localhost {
accessModeString = DashboardAccessMode.network.rawValue accessModeString = DashboardAccessMode.network.rawValue
@ -302,6 +305,8 @@ private struct SecuritySection: View {
_ = dashboardKeychain.deletePassword() _ = dashboardKeychain.deletePassword()
showPasswordFields = false showPasswordFields = false
passwordSaved = false passwordSaved = false
// Clear cached password in LazyBasicAuthMiddleware
LazyBasicAuthMiddleware<BasicRequestContext>.clearCache()
} }
} }

View file

@ -1,6 +1,9 @@
import Foundation 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 { enum SettingsTab: String, CaseIterable {
case general case general
case dashboard case dashboard

View file

@ -1,6 +1,10 @@
import SwiftUI 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 { struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general @State private var selectedTab: SettingsTab = .general
@State private var contentSize: CGSize = .zero @State private var contentSize: CGSize = .zero
@ -51,7 +55,6 @@ struct SettingsView: View {
.tag(SettingsTab.about) .tag(SettingsTab.about)
} }
.frame(width: contentSize.width, height: contentSize.height) .frame(width: contentSize.width, height: contentSize.height)
.animatedWindowSizing(size: contentSize)
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in .onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
if let tab = notification.object as? SettingsTab { if let tab = notification.object as? SettingsTab {
selectedTab = tab 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 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 { struct WelcomeView: View {
@State private var currentPage = 0 @State private var currentPage = 0
@Environment(\.dismiss) @Environment(\.dismiss)
@ -101,7 +106,8 @@ struct WelcomeView: View {
// MARK: - Welcome Page // MARK: - Welcome Page
struct WelcomePageView: View { /// First page of the welcome flow introducing VibeTunnel.
private struct WelcomePageView: View {
var body: some View { var body: some View {
VStack(spacing: 40) { VStack(spacing: 40) {
Spacer() Spacer()
@ -117,7 +123,7 @@ struct WelcomePageView: View {
.font(.largeTitle) .font(.largeTitle)
.fontWeight(.semibold) .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) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -141,7 +147,8 @@ struct WelcomePageView: View {
// MARK: - VT Command Page // 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 cliInstaller: CLIInstaller
var body: some View { var body: some View {
@ -225,7 +232,8 @@ struct VTCommandPageView: View {
// MARK: - Protect Dashboard Page // 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 password = ""
@State private var confirmPassword = "" @State private var confirmPassword = ""
@State private var showError = false @State private var showError = false
@ -352,7 +360,8 @@ struct ProtectDashboardPageView: View {
// MARK: - Access Dashboard Page // MARK: - Access Dashboard Page
struct AccessDashboardPageView: View { /// Fourth page showing how to access the dashboard and ngrok integration.
private struct AccessDashboardPageView: View {
@AppStorage("ngrokEnabled") @AppStorage("ngrokEnabled")
private var ngrokEnabled = false private var ngrokEnabled = false
@AppStorage("serverPort") @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 // MARK: - Preview
struct WelcomeView_Previews: PreviewProvider { struct WelcomeView_Previews: PreviewProvider {

View file

@ -321,7 +321,7 @@ final class ApplicationMover {
Task { @MainActor in Task { @MainActor in
do { do {
let configuration = NSWorkspace.OpenConfiguration() 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") logger.info("Launched app from Applications, quitting current instance")
// Quit current instance after a short delay to ensure the new one starts // Quit current instance after a short delay to ensure the new one starts

View file

@ -2,7 +2,11 @@ import AppKit
import Foundation import Foundation
import SwiftUI 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 @MainActor
enum SettingsOpener { enum SettingsOpener {
/// SwiftUI's hardcoded settings window identifier /// SwiftUI's hardcoded settings window identifier
@ -140,8 +144,11 @@ enum SettingsOpener {
// MARK: - Hidden Window View // MARK: - Hidden Window View
/// A hidden window view that enables Settings to work in MenuBarExtra-only apps /// A hidden window view that enables Settings to work in MenuBarExtra-only apps.
/// This is a workaround for FB10184971 ///
/// 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 { struct HiddenWindowView: View {
@Environment(\.openSettings) @Environment(\.openSettings)
private var openSettings private var openSettings

View file

@ -2,6 +2,10 @@ import AppKit
import Foundation import Foundation
import SwiftUI import SwiftUI
/// Supported terminal applications.
///
/// Represents terminal emulators that VibeTunnel can launch
/// with commands, including detection of installed terminals.
enum Terminal: String, CaseIterable { enum Terminal: String, CaseIterable {
case terminal = "Terminal" case terminal = "Terminal"
case iTerm2 = "iTerm2" case iTerm2 = "iTerm2"
@ -37,10 +41,14 @@ enum Terminal: String, CaseIterable {
} }
static var installed: [Self] { 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 { enum TerminalLauncherError: LocalizedError {
case terminalNotFound case terminalNotFound
case appleScriptPermissionDenied 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 @MainActor
final class TerminalLauncher { final class TerminalLauncher {
static let shared = TerminalLauncher() static let shared = TerminalLauncher()

View file

@ -1,7 +1,11 @@
import AppKit import AppKit
import SwiftUI 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 @MainActor
final class WelcomeWindowController: NSWindowController { final class WelcomeWindowController: NSWindowController {
static let shared = WelcomeWindowController() 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 { match poll_result {
-1 => { -1 => {
let errno = unsafe { *libc::__error() }; let errno = std::io::Error::last_os_error();
return Err(anyhow!( return Err(anyhow!("Poll failed: {}", errno));
"Poll failed: {}",
std::io::Error::from_raw_os_error(errno)
));
} }
0 => { 0 => {
return Err(anyhow!("Write operation timed out after {:?}", timeout)); return Err(anyhow!("Write operation timed out after {:?}", timeout));