mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-16 13:05:53 +00:00
Fix platform-specific CI issues
- Include CreditLink component directly in AboutView.swift - Fix Swift 6 concurrency issue with NSRunningApplication - Remove Windows build from Rust workflow (tty-fwd is Unix-only) - tty-fwd uses Unix-specific PTY features not available on Windows
This commit is contained in:
parent
4e1b59105d
commit
fc27f84756
9 changed files with 270 additions and 13 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.
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue