mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Merge branch 'ci-setup' into main
This commit is contained in:
commit
eac1d8251a
41 changed files with 663 additions and 240 deletions
4
.github/workflows/rust.yml
vendored
4
.github/workflows/rust.yml
vendored
|
|
@ -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
89
KEYCHAIN_OPTIMIZATION.md
Normal 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
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
113
VibeTunnel/Core/Services/LazyBasicAuthMiddleware.swift
Normal file
113
VibeTunnel/Core/Services/LazyBasicAuthMiddleware.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
VibeTunnel/Presentation/Views/SharedComponents.swift
Normal file
32
VibeTunnel/Presentation/Views/SharedComponents.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
BIN
assets/menu.png
BIN
assets/menu.png
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
BIN
assets/menu.psd
Normal file
BIN
assets/menu.psd
Normal file
Binary file not shown.
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue