mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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
|
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
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
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 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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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? {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue