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:
Peter Steinberger 2025-06-17 01:45:07 +02:00
parent 4e1b59105d
commit fc27f84756
9 changed files with 270 additions and 13 deletions

View file

@ -52,10 +52,6 @@ jobs:
target: aarch64-apple-darwin
name: macOS ARM64
binary-name: tty-fwd
- os: windows-latest
target: x86_64-pc-windows-msvc
name: Windows x86_64
binary-name: tty-fwd.exe
runs-on: ${{ matrix.os }}
steps:

89
KEYCHAIN_OPTIMIZATION.md Normal file
View file

@ -0,0 +1,89 @@
# Keychain Access Dialog Investigation Results
## Problem Summary
The keychain access dialog appears on every restart because:
1. Password is accessed immediately when the server starts (both TunnelServer and RustServer)
2. NgrokService's auth token is accessed when checking status
3. No in-memory caching of credentials after first access
## Where Keychain Access is Triggered
### 1. Server Startup (Every time the app launches)
- **TunnelServer.swift:146**: `if let password = DashboardKeychain.shared.getPassword()`
- **RustServer.swift:162**: `if let password = DashboardKeychain.shared.getPassword()`
- Both servers check for password on startup to configure basic auth middleware
### 2. Settings View
- **DashboardSettingsView.swift:114**: Checks if password exists on view appear
- **DashboardSettingsView.swift:259**: When revealing ngrok token
- **WelcomeView.swift:342**: When setting password during onboarding
### 3. NgrokService
- **NgrokService.swift:85-87**: Getting auth token (triggers keychain)
- **NgrokService.swift:117-121**: When starting ngrok tunnel
## Recommended Solutions
### 1. Implement In-Memory Caching
Create a secure in-memory cache for credentials that survives the app session:
```swift
@MainActor
final class CredentialCache {
static let shared = CredentialCache()
private var dashboardPassword: String?
private var ngrokAuthToken: String?
private var lastAccessTime: Date?
private init() {}
func getDashboardPassword() -> String? {
if let password = dashboardPassword {
return password
}
// Fall back to keychain only if not cached
dashboardPassword = DashboardKeychain.shared.getPassword()
return dashboardPassword
}
func clearCache() {
dashboardPassword = nil
ngrokAuthToken = nil
}
}
```
### 2. Defer Keychain Access Until Needed
- Don't access keychain on server startup unless password protection is enabled
- Check `UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled")` first
- Only access keychain when actually authenticating a request
### 3. Use Keychain Access Groups
Configure the app to use a shared keychain access group to reduce prompts:
- Add keychain access group to entitlements
- Update keychain queries to include the access group
### 4. Batch Keychain Operations
When multiple keychain accesses are needed, batch them together to minimize prompts.
### 5. Add "hasPassword" Check Without Retrieval
Both DashboardKeychain and NgrokService already have this implemented:
- `DashboardKeychain.hasPassword()` (line 45-59)
- `NgrokService.hasAuthToken` (line 100-102)
Use these checks before attempting to retrieve values.
## Immediate Fix Implementation
The quickest fix is to defer password retrieval until an authenticated request arrives:
1. Modify server implementations to not retrieve password on startup
2. Add lazy initialization for basic auth middleware
3. Cache credentials after first successful retrieval
4. Only check if password exists (using `hasPassword()`) on startup
This will reduce keychain prompts from every startup to only when:
- First authenticated request arrives
- User explicitly accesses credentials in settings
- Credentials are changed/updated

View file

@ -0,0 +1,113 @@
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import NIOCore
import os
/// Middleware that implements HTTP Basic Authentication with lazy password loading.
///
/// This middleware defers keychain access until an authenticated request is received,
/// preventing unnecessary keychain prompts on app startup. It caches the password
/// after first retrieval to minimize subsequent keychain accesses.
@MainActor
struct LazyBasicAuthMiddleware<Context: RequestContext>: RouterMiddleware {
private let realm: String
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "LazyBasicAuth")
/// Cached password to avoid repeated keychain access
private static var cachedPassword: String?
init(realm: String = "VibeTunnel Dashboard") {
self.realm = realm
}
func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
)
async throws -> Response
{
// Skip auth for health check endpoint
if request.uri.path == "/api/health" {
return try await next(request, context)
}
// Check if password protection is enabled
guard UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") else {
// No password protection, allow request
return try await next(request, context)
}
// Extract authorization header
guard let authHeader = request.headers[.authorization],
authHeader.hasPrefix("Basic ")
else {
return unauthorizedResponse()
}
// Decode base64 credentials
let base64Credentials = String(authHeader.dropFirst(6))
guard let credentialsData = Data(base64Encoded: base64Credentials),
let credentials = String(data: credentialsData, encoding: .utf8)
else {
return unauthorizedResponse()
}
// Split username:password
let parts = credentials.split(separator: ":", maxSplits: 1)
guard parts.count == 2 else {
return unauthorizedResponse()
}
// We ignore the username and only check password
let providedPassword = String(parts[1])
// Get password (cached or from keychain)
let requiredPassword: String
if let cached = Self.cachedPassword {
requiredPassword = cached
logger.debug("Using cached password")
} else {
// First authentication attempt - access keychain
guard let password = await MainActor.run(body: {
DashboardKeychain.shared.getPassword()
}) else {
logger.error("Password protection enabled but no password found in keychain")
return unauthorizedResponse()
}
Self.cachedPassword = password
requiredPassword = password
logger.info("Password loaded from keychain and cached")
}
// Verify password
guard providedPassword == requiredPassword else {
return unauthorizedResponse()
}
// Password correct, continue with request
return try await next(request, context)
}
private func unauthorizedResponse() -> Response {
var headers = HTTPFields()
headers[.wwwAuthenticate] = "Basic realm=\"\(realm)\""
let message = "Authentication required"
var buffer = ByteBuffer()
buffer.writeString(message)
return Response(
status: .unauthorized,
headers: headers,
body: ResponseBody(byteBuffer: buffer)
)
}
/// Clears the cached password (useful when password is changed)
static func clearCache() {
cachedPassword = nil
}
}

View file

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

View file

@ -2,7 +2,10 @@ import Foundation
import HTTPTypes
import Logging
/// WebSocket message types for terminal communication
/// WebSocket message types for terminal communication.
///
/// Defines the different types of messages that can be exchanged
/// between the client and server over WebSocket connections.
public enum WSMessageType: String, Codable {
case connect
case command
@ -13,7 +16,10 @@ public enum WSMessageType: String, Codable {
case close
}
/// WebSocket message structure
/// WebSocket message structure.
///
/// Encapsulates data sent over WebSocket connections for terminal
/// communication, including message type, session information, and payload.
public struct WSMessage: Codable {
public let type: WSMessageType
public let sessionId: String?
@ -28,7 +34,11 @@ public struct WSMessage: Codable {
}
}
/// Client SDK for interacting with the VibeTunnel server
/// Client SDK for interacting with the VibeTunnel server.
///
/// Provides a high-level interface for creating and managing terminal sessions
/// through the VibeTunnel HTTP API. Handles authentication, request/response
/// serialization, and error handling for all server operations.
public class TunnelClient {
private let baseURL: URL
private let apiKey: String
@ -220,7 +230,11 @@ public class TunnelClient {
}
}
/// WebSocket client for real-time terminal communication
/// WebSocket client for real-time terminal communication.
///
/// Provides WebSocket connectivity for streaming terminal I/O and
/// receiving real-time updates from terminal sessions. Handles
/// authentication, message encoding/decoding, and connection lifecycle.
public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
private let url: URL
private let apiKey: String
@ -340,6 +354,10 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
// MARK: - Errors
/// Errors that can occur when using the TunnelClient.
///
/// Represents various failure modes including network errors,
/// server errors, and data decoding issues.
public enum TunnelClientError: LocalizedError, Equatable {
case invalidResponse
case httpError(statusCode: Int)

View file

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

View file

@ -321,7 +321,7 @@ final class ApplicationMover {
Task { @MainActor in
do {
let configuration = NSWorkspace.OpenConfiguration()
_ = try await workspace.openApplication(at: appURL, configuration: configuration)
try await workspace.openApplication(at: appURL, configuration: configuration)
logger.info("Launched app from Applications, quitting current instance")
// Quit current instance after a short delay to ensure the new one starts

View file

@ -2,7 +2,11 @@ import AppKit
import Foundation
import SwiftUI
/// Helper to open the Settings window programmatically when SettingsLink cannot be used
/// Helper to open the Settings window programmatically when SettingsLink cannot be used.
///
/// Provides workarounds for opening the Settings window in menu bar apps where
/// SwiftUI's SettingsLink may not function correctly. Uses multiple strategies
/// including menu item triggering and window manipulation to ensure reliable behavior.
@MainActor
enum SettingsOpener {
/// SwiftUI's hardcoded settings window identifier
@ -140,8 +144,11 @@ enum SettingsOpener {
// MARK: - Hidden Window View
/// A hidden window view that enables Settings to work in MenuBarExtra-only apps
/// This is a workaround for FB10184971
/// A hidden window view that enables Settings to work in MenuBarExtra-only apps.
///
/// This is a workaround for FB10184971 where SettingsLink doesn't function
/// properly in menu bar apps. Creates an invisible window that can receive
/// the openSettings environment action.
struct HiddenWindowView: View {
@Environment(\.openSettings)
private var openSettings