Simplify Mac app now that have ONE TRUE SERVER

This commit is contained in:
Peter Steinberger 2025-06-20 12:30:59 +02:00
parent ce1dd762e9
commit e1afae29c8
11 changed files with 132 additions and 1179 deletions

View file

@ -1,14 +1,34 @@
import Foundation import Foundation
import OSLog import OSLog
/// Log entry from the server.
struct ServerLogEntry {
/// Severity level of the log entry.
enum Level {
case debug
case info
case warning
case error
}
let timestamp: Date
let level: Level
let message: String
init(level: Level = .info, message: String) {
self.timestamp = Date()
self.level = level
self.message = message
}
}
/// Go vibetunnel server implementation. /// Go vibetunnel server implementation.
/// ///
/// Manages the external vibetunnel Go binary as a subprocess. This implementation /// Manages the external vibetunnel Go binary as a subprocess. This implementation
/// provides high-performance terminal multiplexing by leveraging the Go-based /// provides high-performance terminal multiplexing by leveraging the Go-based
/// vibetunnel server. It handles process lifecycle, log streaming, and error recovery /// vibetunnel server. It handles process lifecycle, log streaming, and error recovery.
/// while maintaining compatibility with the ServerProtocol interface.
@MainActor @MainActor
final class GoServer: ServerProtocol { final class GoServer {
private var process: Process? private var process: Process?
private var stdoutPipe: Pipe? private var stdoutPipe: Pipe?
private var stderrPipe: Pipe? private var stderrPipe: Pipe?
@ -63,8 +83,6 @@ final class GoServer: ServerProtocol {
private let processHandler = ProcessHandler() private let processHandler = ProcessHandler()
var serverType: ServerMode { .go }
private(set) var isRunning = false private(set) var isRunning = false
var port: String = "" { var port: String = "" {
@ -97,15 +115,14 @@ final class GoServer: ServerProtocol {
guard !port.isEmpty else { guard !port.isEmpty else {
let error = GoServerError.invalidPort let error = GoServerError.invalidPort
logger.error("Port not configured") logger.error("Port not configured")
logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .go)) logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription))
throw error throw error
} }
logger.info("Starting Go vibetunnel server on port \(self.port)") logger.info("Starting Go vibetunnel server on port \(self.port)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Initializing Go vibetunnel server...", message: "Initializing Go vibetunnel server..."
source: .go
)) ))
// Get the vibetunnel binary path // Get the vibetunnel binary path
@ -118,8 +135,7 @@ final class GoServer: ServerProtocol {
logger.error("Go was not available during build") logger.error("Go was not available during build")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Go server is not available. Please install Go and rebuild the app to enable Go server support.", message: "Go server is not available. Please install Go and rebuild the app to enable Go server support."
source: .go
)) ))
throw error throw error
} }
@ -127,7 +143,7 @@ final class GoServer: ServerProtocol {
guard let binaryPath else { guard let binaryPath else {
let error = GoServerError.binaryNotFound let error = GoServerError.binaryNotFound
logger.error("vibetunnel binary not found in bundle") logger.error("vibetunnel binary not found in bundle")
logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .go)) logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription))
throw error throw error
} }
@ -151,15 +167,13 @@ final class GoServer: ServerProtocol {
// Log binary architecture info // Log binary architecture info
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .debug, level: .debug,
message: "Binary path: \(binaryPath)", message: "Binary path: \(binaryPath)"
source: .go
)) ))
} else if !fileExists { } else if !fileExists {
logger.error("vibetunnel binary NOT FOUND at: \(binaryPath)") logger.error("vibetunnel binary NOT FOUND at: \(binaryPath)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Binary not found at: \(binaryPath)", message: "Binary not found at: \(binaryPath)"
source: .go
)) ))
} }
@ -182,8 +196,7 @@ final class GoServer: ServerProtocol {
logger.error("Web directory not found at expected location: \(staticPath)") logger.error("Web directory not found at expected location: \(staticPath)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Web directory not found at: \(staticPath)", message: "Web directory not found at: \(staticPath)"
source: .go
)) ))
} }
@ -265,43 +278,36 @@ final class GoServer: ServerProtocol {
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Server failed to start: \(errorDetails)", message: "Server failed to start: \(errorDetails)"
source: .go
)) ))
throw GoServerError.processFailedToStart throw GoServerError.processFailedToStart
} }
logger.info("Go server process started, performing health check...") logger.info("Go server process started, performing health check...")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check...", source: .go)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check..."))
// Perform health check to ensure server is actually responding // Perform health check to ensure server is actually responding
let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5) let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5)
if isHealthy { if isHealthy {
logger.info("Go server started successfully and is responding") logger.info("Go server started successfully and is responding")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .go)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Health check passed ✓"))
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Go vibetunnel server is ready", message: "Go vibetunnel server is ready"
source: .go
)) ))
// Monitor process termination with task context // Monitor process termination
Task { Task {
await ServerTaskContext.$taskName.withValue("GoServer-monitor-\(port)") { await monitorProcessTermination()
await ServerTaskContext.$serverType.withValue(.go) {
await monitorProcessTermination()
}
}
} }
} else { } else {
// Server process is running but not responding // Server process is running but not responding
logger.error("Go server process started but is not responding to health checks") logger.error("Go server process started but is not responding to health checks")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Health check failed - server not responding", message: "Health check failed - server not responding"
source: .go
)) ))
// Clean up the non-responsive process // Clean up the non-responsive process
@ -332,8 +338,7 @@ final class GoServer: ServerProtocol {
logger.error("Failed to start Go server: \(errorMessage)") logger.error("Failed to start Go server: \(errorMessage)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to start Go server: \(errorMessage)", message: "Failed to start Go server: \(errorMessage)"
source: .go
)) ))
throw error throw error
} }
@ -348,8 +353,7 @@ final class GoServer: ServerProtocol {
logger.info("Stopping Go server") logger.info("Stopping Go server")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Shutting down Go vibetunnel server...", message: "Shutting down Go vibetunnel server..."
source: .go
)) ))
// Cancel output monitoring tasks // Cancel output monitoring tasks
@ -370,8 +374,7 @@ final class GoServer: ServerProtocol {
logger.warning("Force killed Go server after timeout") logger.warning("Force killed Go server after timeout")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Force killed server after timeout", message: "Force killed server after timeout"
source: .go
)) ))
} }
@ -386,14 +389,13 @@ final class GoServer: ServerProtocol {
logger.info("Go server stopped") logger.info("Go server stopped")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Go vibetunnel server shutdown complete", message: "Go vibetunnel server shutdown complete"
source: .go
)) ))
} }
func restart() async throws { func restart() async throws {
logger.info("Restarting Go server") logger.info("Restarting Go server")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Restarting server", source: .go)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Restarting server"))
await stop() await stop()
try await start() try await start()
@ -414,8 +416,7 @@ final class GoServer: ServerProtocol {
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .debug, level: .debug,
message: "Health check attempt \(attempt)/\(maxAttempts)...", message: "Health check attempt \(attempt)/\(maxAttempts)..."
source: .go
)) ))
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
@ -429,8 +430,7 @@ final class GoServer: ServerProtocol {
if attempt == maxAttempts { if attempt == maxAttempts {
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Health check failed after \(maxAttempts) attempts", message: "Health check failed after \(maxAttempts) attempts"
source: .go
)) ))
} }
} }
@ -452,79 +452,69 @@ final class GoServer: ServerProtocol {
// Monitor stdout on background thread // Monitor stdout on background thread
outputTask = Task.detached { [weak self] in outputTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("GoServer-stdout-\(currentPort)") { guard let self, let pipe = stdoutPipe else { return }
ServerTaskContext.$serverType.withValue(.go) {
guard let self, let pipe = stdoutPipe else { return }
let handle = pipe.fileHandleForReading let handle = pipe.fileHandleForReading
self.logger.debug("Starting stdout monitoring for Go server on port \(currentPort)") self.logger.debug("Starting stdout monitoring for Go server on port \(currentPort)")
while !Task.isCancelled { while !Task.isCancelled {
autoreleasepool { autoreleasepool {
let data = handle.availableData let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines) .components(separatedBy: .newlines)
for line in lines where !line.isEmpty { for line in lines where !line.isEmpty {
// Skip shell initialization messages // Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") { if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue continue
} }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
let level = self.detectLogLevel(from: line) let level = self.detectLogLevel(from: line)
self.logContinuation?.yield(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: level, level: level,
message: line, message: line
source: .go ))
))
}
}
} }
} }
} }
self.logger.debug("Stopped stdout monitoring for Go server")
} }
} }
self.logger.debug("Stopped stdout monitoring for Go server")
} }
// Monitor stderr on background thread // Monitor stderr on background thread
errorTask = Task.detached { [weak self] in errorTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("GoServer-stderr-\(currentPort)") { guard let self, let pipe = stderrPipe else { return }
ServerTaskContext.$serverType.withValue(.go) {
guard let self, let pipe = stderrPipe else { return }
let handle = pipe.fileHandleForReading let handle = pipe.fileHandleForReading
self.logger.debug("Starting stderr monitoring for Go server on port \(currentPort)") self.logger.debug("Starting stderr monitoring for Go server on port \(currentPort)")
while !Task.isCancelled { while !Task.isCancelled {
autoreleasepool { autoreleasepool {
let data = handle.availableData let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines) .components(separatedBy: .newlines)
for line in lines where !line.isEmpty { for line in lines where !line.isEmpty {
// Skip shell initialization messages // Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") { if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue continue
} }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
self.logContinuation?.yield(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: line, message: line
source: .go ))
))
}
}
} }
} }
} }
self.logger.debug("Stopped stderr monitoring for Go server")
} }
} }
self.logger.debug("Stopped stderr monitoring for Go server")
} }
} }
@ -540,8 +530,7 @@ final class GoServer: ServerProtocol {
self.logger.error("Go server terminated unexpectedly with exit code: \(exitCode)") self.logger.error("Go server terminated unexpectedly with exit code: \(exitCode)")
self.logContinuation?.yield(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Server terminated unexpectedly with exit code: \(exitCode)", message: "Server terminated unexpectedly with exit code: \(exitCode)"
source: .go
)) ))
self.isRunning = false self.isRunning = false
@ -553,8 +542,7 @@ final class GoServer: ServerProtocol {
self.logger.info("Auto-restarting Go server after crash") self.logger.info("Auto-restarting Go server after crash")
self.logContinuation?.yield(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Auto-restarting server after crash", message: "Auto-restarting server after crash"
source: .go
)) ))
try? await self.start() try? await self.start()
} }

View file

@ -1,627 +0,0 @@
import Foundation
import OSLog
/// Task tracking for better debugging.
///
/// Provides task-local storage for debugging context during
/// asynchronous server operations.
enum ServerTaskContext {
@TaskLocal static var taskName: String?
@TaskLocal static var serverType: ServerMode?
}
/// Rust tty-fwd server implementation.
///
/// Manages the external tty-fwd Rust binary as a subprocess. This implementation
/// provides high-performance terminal multiplexing by leveraging the battle-tested
/// tty-fwd server. It handles process lifecycle, log streaming, and error recovery
/// while maintaining compatibility with the ServerProtocol interface.
@MainActor
final class RustServer: ServerProtocol {
private var process: Process?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var outputTask: Task<Void, Never>?
private var errorTask: Task<Void, Never>?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "RustServer")
private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
private let processQueue = DispatchQueue(label: "sh.vibetunnel.vibetunnel.RustServer", qos: .userInitiated)
/// Actor to handle process operations on background thread.
///
/// Isolates process management operations to prevent blocking the main thread
/// while maintaining Swift concurrency safety.
private actor ProcessHandler {
private let queue = DispatchQueue(
label: "sh.vibetunnel.vibetunnel.RustServer.ProcessHandler",
qos: .userInitiated
)
func runProcess(_ process: Process) async throws {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
try process.run()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func waitForExit(_ process: Process) async {
await withCheckedContinuation { continuation in
queue.async {
process.waitUntilExit()
continuation.resume()
}
}
}
func terminateProcess(_ process: Process) async {
await withCheckedContinuation { continuation in
queue.async {
process.terminate()
continuation.resume()
}
}
}
}
private let processHandler = ProcessHandler()
var serverType: ServerMode { .rust }
private(set) var isRunning = false
var port: String = "" {
didSet {
// If server is running and port changed, we need to restart
if isRunning && oldValue != port {
Task {
try? await restart()
}
}
}
}
let logStream: AsyncStream<ServerLogEntry>
init() {
var localContinuation: AsyncStream<ServerLogEntry>.Continuation?
self.logStream = AsyncStream { continuation in
localContinuation = continuation
}
self.logContinuation = localContinuation
}
func start() async throws {
guard !isRunning else {
logger.warning("Rust server already running")
return
}
guard !port.isEmpty else {
let error = RustServerError.invalidPort
logger.error("Port not configured")
logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error
}
logger.info("Starting Rust tty-fwd server on port \(self.port)")
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Initializing Rust tty-fwd server...",
source: .rust
))
// Get the tty-fwd binary path
let binaryPath = Bundle.main.path(forResource: "tty-fwd", ofType: nil)
guard let binaryPath else {
let error = RustServerError.binaryNotFound
logger.error("tty-fwd binary not found in bundle")
logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error
}
// Ensure binary is executable
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath)
// Verify binary exists and is executable
var isDirectory: ObjCBool = false
let fileExists = FileManager.default.fileExists(atPath: binaryPath, isDirectory: &isDirectory)
logger.info("tty-fwd binary exists: \(fileExists), is directory: \(isDirectory.boolValue)")
if fileExists && !isDirectory.boolValue {
let attributes = try FileManager.default.attributesOfItem(atPath: binaryPath)
if let permissions = attributes[.posixPermissions] as? NSNumber {
logger.info("tty-fwd binary permissions: \(String(permissions.intValue, radix: 8))")
}
if let fileSize = attributes[.size] as? NSNumber {
logger.info("tty-fwd binary size: \(fileSize.intValue) bytes")
}
// Log binary architecture info
logContinuation?.yield(ServerLogEntry(
level: .debug,
message: "Binary path: \(binaryPath)",
source: .rust
))
} else if !fileExists {
logger.error("tty-fwd binary NOT FOUND at: \(binaryPath)")
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Binary not found at: \(binaryPath)",
source: .rust
))
}
// Create the process using login shell
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// Get the Resources directory path
let bundlePath = Bundle.main.bundlePath
let resourcesPath = Bundle.main.resourcePath ?? bundlePath
// Set working directory to Resources directory where both tty-fwd and web folder exist
process.currentDirectoryURL = URL(fileURLWithPath: resourcesPath)
logger.info("Setting working directory to: \(resourcesPath)")
// The web/public directory should be at web/public relative to Resources
let webPublicPath = URL(fileURLWithPath: resourcesPath).appendingPathComponent("web/public")
let webPublicExists = FileManager.default.fileExists(atPath: webPublicPath.path)
logger.info("Web public directory at \(webPublicPath.path) exists: \(webPublicExists)")
if !webPublicExists {
logger.error("Web public directory NOT FOUND at: \(webPublicPath.path)")
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Web public directory not found at: \(webPublicPath.path)",
source: .rust
))
// List contents of Resources directory for debugging
if let contents = try? FileManager.default.contentsOfDirectory(atPath: resourcesPath) {
logger.debug("Resources directory contents: \(contents.joined(separator: ", "))")
}
}
// Use absolute path for static directory
let staticPath = webPublicPath.path
// Build command to run tty-fwd through login shell
// Use bind address from ServerManager to control server accessibility
let bindAddress = ServerManager.shared.bindAddress
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled
// Only check if password exists, don't retrieve it yet
if UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") && DashboardKeychain.shared.hasPassword() {
// Defer actual password retrieval until first authenticated request
// For now, we'll use a placeholder that the Rust server will replace
// when it needs to authenticate
logger.info("Password protection enabled, deferring keychain access")
// Note: The Rust server needs to be updated to support lazy password loading
// For now, we still need to access the keychain here
if let password = DashboardKeychain.shared.getPassword() {
// Escape the password for shell
let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "$", with: "\\$")
.replacingOccurrences(of: "`", with: "\\`")
.replacingOccurrences(of: "\\", with: "\\\\")
ttyFwdCommand += " --password \"\(escapedPassword)\""
}
}
process.arguments = ["-l", "-c", ttyFwdCommand]
logger.info("Executing command: /bin/zsh -l -c \"\(ttyFwdCommand)\"")
logger.info("Working directory: \(resourcesPath)")
// Set up environment - login shell will load the rest
var environment = ProcessInfo.processInfo.environment
environment["RUST_LOG"] = "info"
process.environment = environment
// Set up pipes for stdout and stderr
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
self.process = process
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
// Start monitoring output
startOutputMonitoring()
do {
// Start the process (this just launches it and returns immediately)
try await processHandler.runProcess(process)
// Mark server as running
isRunning = true
logger.info("Rust server process started")
// Give the process a moment to start before checking for early failures
try await Task.sleep(for: .milliseconds(100))
// Check if process exited immediately (indicating failure)
if !process.isRunning {
isRunning = false
let exitCode = process.terminationStatus
logger.error("Process exited immediately with code: \(exitCode)")
// Try to read any error output
var errorDetails = "Exit code: \(exitCode)"
if let stderrPipe = self.stderrPipe {
let errorData = stderrPipe.fileHandleForReading.availableData
if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) {
errorDetails += "\nError: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))"
}
}
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Server failed to start: \(errorDetails)",
source: .rust
))
throw RustServerError.processFailedToStart
}
logger.info("Rust server process started, performing health check...")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust))
// Perform health check to ensure server is actually responding
let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5)
if isHealthy {
logger.info("Rust server started successfully and is responding")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .rust))
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Rust tty-fwd server is ready",
source: .rust
))
// Monitor process termination with task context
Task {
await ServerTaskContext.$taskName.withValue("RustServer-monitor-\(port)") {
await ServerTaskContext.$serverType.withValue(.rust) {
await monitorProcessTermination()
}
}
}
} else {
// Server process is running but not responding
logger.error("Rust server process started but is not responding to health checks")
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Health check failed - server not responding",
source: .rust
))
// Clean up the non-responsive process
process.terminate()
self.process = nil
self.stdoutPipe = nil
self.stderrPipe = nil
isRunning = false
throw RustServerError.serverNotResponding
}
} catch {
isRunning = false
// Log more detailed error information
let errorMessage: String
if let rustError = error as? RustServerError {
errorMessage = rustError.localizedDescription
} else if let nsError = error as NSError? {
errorMessage = "\(nsError.localizedDescription) (Code: \(nsError.code), Domain: \(nsError.domain))"
if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] {
logger.error("Underlying error: \(String(describing: underlyingError))")
}
} else {
errorMessage = String(describing: error)
}
logger.error("Failed to start Rust server: \(errorMessage)")
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Failed to start Rust server: \(errorMessage)",
source: .rust
))
throw error
}
}
func stop() async {
guard let process, isRunning else {
logger.warning("Rust server not running")
return
}
logger.info("Stopping Rust server")
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Shutting down Rust tty-fwd server...",
source: .rust
))
// Cancel output monitoring tasks
outputTask?.cancel()
errorTask?.cancel()
// Terminate the process on background thread
await processHandler.terminateProcess(process)
// Wait for process to terminate (with timeout)
let terminated: Void? = await withTimeoutOrNil(seconds: 5) { [self] in
await self.processHandler.waitForExit(process)
}
if terminated == nil {
// Force kill if termination timeout
process.interrupt()
logger.warning("Force killed Rust server after timeout")
logContinuation?.yield(ServerLogEntry(
level: .warning,
message: "Force killed server after timeout",
source: .rust
))
}
// Clean up
self.process = nil
self.stdoutPipe = nil
self.stderrPipe = nil
self.outputTask = nil
self.errorTask = nil
isRunning = false
logger.info("Rust server stopped")
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Rust tty-fwd server shutdown complete",
source: .rust
))
}
func restart() async throws {
logger.info("Restarting Rust server")
logContinuation?.yield(ServerLogEntry(level: .info, message: "Restarting server", source: .rust))
await stop()
try await start()
}
// MARK: - Private Methods
private func performHealthCheck(maxAttempts: Int, delaySeconds: Double) async -> Bool {
guard let healthURL = URL(string: "http://127.0.0.1:\(port)/api/health") else {
return false
}
for attempt in 1...maxAttempts {
do {
// Create request with short timeout
var request = URLRequest(url: healthURL)
request.timeoutInterval = 2.0
logContinuation?.yield(ServerLogEntry(
level: .debug,
message: "Health check attempt \(attempt)/\(maxAttempts)...",
source: .rust
))
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
logger.debug("Health check succeeded on attempt \(attempt)")
return true
}
} catch {
logger.debug("Health check attempt \(attempt) failed: \(error.localizedDescription)")
if attempt == maxAttempts {
logContinuation?.yield(ServerLogEntry(
level: .warning,
message: "Health check failed after \(maxAttempts) attempts",
source: .rust
))
}
}
// Wait before next attempt (except on last attempt)
if attempt < maxAttempts {
try? await Task.sleep(for: .seconds(delaySeconds))
}
}
return false
}
private func startOutputMonitoring() {
// Capture pipes and port before starting detached tasks
let stdoutPipe = self.stdoutPipe
let stderrPipe = self.stderrPipe
let currentPort = self.port
// Monitor stdout on background thread
outputTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("RustServer-stdout-\(currentPort)") {
ServerTaskContext.$serverType.withValue(.rust) {
guard let self, let pipe = stdoutPipe else { return }
let handle = pipe.fileHandleForReading
self.logger.debug("Starting stdout monitoring for Rust server on port \(currentPort)")
while !Task.isCancelled {
autoreleasepool {
let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
Task { @MainActor [weak self] in
guard let self else { return }
let level = self.detectLogLevel(from: line)
self.logContinuation?.yield(ServerLogEntry(
level: level,
message: line,
source: .rust
))
}
}
}
}
}
self.logger.debug("Stopped stdout monitoring for Rust server")
}
}
}
// Monitor stderr on background thread
errorTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("RustServer-stderr-\(currentPort)") {
ServerTaskContext.$serverType.withValue(.rust) {
guard let self, let pipe = stderrPipe else { return }
let handle = pipe.fileHandleForReading
self.logger.debug("Starting stderr monitoring for Rust server on port \(currentPort)")
while !Task.isCancelled {
autoreleasepool {
let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
Task { @MainActor [weak self] in
guard let self else { return }
self.logContinuation?.yield(ServerLogEntry(
level: .error,
message: line,
source: .rust
))
}
}
}
}
}
self.logger.debug("Stopped stderr monitoring for Rust server")
}
}
}
}
private func monitorProcessTermination() async {
guard let process else { return }
// Wait for process exit on background thread
await processHandler.waitForExit(process)
if self.isRunning {
// Unexpected termination
let exitCode = process.terminationStatus
self.logger.error("Rust server terminated unexpectedly with exit code: \(exitCode)")
self.logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Server terminated unexpectedly with exit code: \(exitCode)",
source: .rust
))
self.isRunning = false
// Auto-restart on unexpected termination
Task {
try? await Task.sleep(for: .seconds(2))
if self.process == nil { // Only restart if not manually stopped
self.logger.info("Auto-restarting Rust server after crash")
self.logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Auto-restarting server after crash",
source: .rust
))
try? await self.start()
}
}
}
}
private func detectLogLevel(from line: String) -> ServerLogEntry.Level {
let lowercased = line.lowercased()
if lowercased.contains("error") || lowercased.contains("fatal") {
return .error
} else if lowercased.contains("warn") || lowercased.contains("warning") {
return .warning
} else if lowercased.contains("debug") || lowercased.contains("trace") {
return .debug
} else {
return .info
}
}
private func withTimeoutOrNil<T: Sendable>(
seconds: TimeInterval,
operation: @escaping @Sendable () async -> T
)
async -> T?
{
await withTaskGroup(of: T?.self) { group in
group.addTask {
await operation()
}
group.addTask {
try? await Task.sleep(for: .seconds(seconds))
return nil
}
if let result = await group.next() {
group.cancelAll()
return result
}
group.cancelAll()
return nil
}
}
}
// MARK: - Errors
enum RustServerError: LocalizedError {
case binaryNotFound
case processFailedToStart
case serverNotResponding
case invalidPort
var errorDescription: String? {
switch self {
case .binaryNotFound:
"The tty-fwd binary was not found in the app bundle"
case .processFailedToStart:
"The server process failed to start"
case .serverNotResponding:
"The server process started but is not responding to health checks"
case .invalidPort:
"Server port is not configured"
}
}
}

View file

@ -3,22 +3,16 @@ import Observation
import OSLog import OSLog
import SwiftUI import SwiftUI
/// Manages the active server and handles switching between modes. /// Manages the VibeTunnel server lifecycle.
/// ///
/// `ServerManager` is the central coordinator for server lifecycle management in VibeTunnel. /// `ServerManager` is the central coordinator for server lifecycle management in VibeTunnel.
/// It handles starting, stopping, and switching between different server implementations (Rust/Hummingbird), /// It handles starting, stopping, and restarting the Go server, manages server configuration,
/// manages server configuration, and provides logging capabilities. The manager ensures only one /// and provides logging capabilities.
/// server instance runs at a time and coordinates smooth transitions between server modes.
@MainActor @MainActor
@Observable @Observable
class ServerManager { class ServerManager {
@MainActor static let shared = ServerManager() @MainActor static let shared = ServerManager()
private var serverModeString: String {
get { UserDefaults.standard.string(forKey: "serverMode") ?? ServerMode.rust.rawValue }
set { UserDefaults.standard.set(newValue, forKey: "serverMode") }
}
var port: String { var port: String {
get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" } get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" }
set { UserDefaults.standard.set(newValue, forKey: "serverPort") } set { UserDefaults.standard.set(newValue, forKey: "serverPort") }
@ -44,9 +38,8 @@ class ServerManager {
set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") } set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") }
} }
private(set) var currentServer: ServerProtocol? private(set) var currentServer: GoServer?
private(set) var isRunning = false private(set) var isRunning = false
private(set) var isSwitching = false
private(set) var isRestarting = false private(set) var isRestarting = false
private(set) var lastError: Error? private(set) var lastError: Error?
private(set) var crashCount = 0 private(set) var crashCount = 0
@ -59,11 +52,6 @@ class ServerManager {
private var serverLogTask: Task<Void, Never>? private var serverLogTask: Task<Void, Never>?
private(set) var logStream: AsyncStream<ServerLogEntry>! private(set) var logStream: AsyncStream<ServerLogEntry>!
var serverMode: ServerMode {
get { ServerMode(rawValue: serverModeString) ?? .rust }
set { serverModeString = newValue.rawValue }
}
private init() { private init() {
setupLogStream() setupLogStream()
@ -103,9 +91,7 @@ class ServerManager {
@objc @objc
private nonisolated func userDefaultsDidChange() { private nonisolated func userDefaultsDidChange() {
Task { @MainActor in // Server mode is now fixed to Go, no need to handle changes
await handleServerModeChange()
}
} }
/// Start the server with current configuration /// Start the server with current configuration
@ -117,13 +103,11 @@ class ServerManager {
// Ensure our state is synced // Ensure our state is synced
isRunning = true isRunning = true
lastError = nil lastError = nil
ServerMonitor.shared.isServerRunning = true
// Log for clarity // Log for clarity
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "\(serverMode.displayName) server already running on port \(self.port)", message: "Server already running on port \(self.port)"
source: serverMode
)) ))
return return
} }
@ -138,16 +122,14 @@ class ServerManager {
logger.info("Attempting to kill conflicting process: \(processName) (PID: \(pid))") logger.info("Attempting to kill conflicting process: \(processName) (PID: \(pid))")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Port \(self.port) is used by another instance. Terminating conflicting process...", message: "Port \(self.port) is used by another instance. Terminating conflicting process..."
source: serverMode
)) ))
do { do {
try await PortConflictResolver.shared.resolveConflict(conflict) try await PortConflictResolver.shared.resolveConflict(conflict)
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Conflicting process terminated successfully", message: "Conflicting process terminated successfully"
source: serverMode
)) ))
// Wait a moment for port to be fully released // Wait a moment for port to be fully released
@ -157,8 +139,7 @@ class ServerManager {
lastError = PortConflictError.failedToKillProcess(pid: pid) lastError = PortConflictError.failedToKillProcess(pid: pid)
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to terminate conflicting process. Please try a different port.", message: "Failed to terminate conflicting process. Please try a different port."
source: serverMode
)) ))
return return
} }
@ -172,8 +153,7 @@ class ServerManager {
) )
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Port \(self.port) is used by \(appName). Please choose a different port.", message: "Port \(self.port) is used by \(appName). Please choose a different port."
source: serverMode
)) ))
return return
@ -186,12 +166,11 @@ class ServerManager {
// Log that we're starting a server // Log that we're starting a server
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Starting \(serverMode.displayName) server on port \(self.port)...", message: "Starting server on port \(self.port)..."
source: serverMode
)) ))
do { do {
let server = createServer(for: serverMode) let server = GoServer()
server.port = port server.port = port
// Subscribe to server logs // Subscribe to server logs
@ -207,10 +186,7 @@ class ServerManager {
isRunning = true isRunning = true
lastError = nil lastError = nil
logger.info("Started \(self.serverMode.displayName) server on port \(self.port)") logger.info("Started server on port \(self.port)")
// Update ServerMonitor for compatibility
ServerMonitor.shared.isServerRunning = true
// Trigger cleanup of old sessions after server starts // Trigger cleanup of old sessions after server starts
await triggerInitialCleanup() await triggerInitialCleanup()
@ -218,8 +194,7 @@ class ServerManager {
logger.error("Failed to start server: \(error.localizedDescription)") logger.error("Failed to start server: \(error.localizedDescription)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to start \(serverMode.displayName) server: \(error.localizedDescription)", message: "Failed to start server: \(error.localizedDescription)"
source: serverMode
)) ))
lastError = error lastError = error
@ -227,10 +202,8 @@ class ServerManager {
if let server = currentServer, server.isRunning { if let server = currentServer, server.isRunning {
logger.warning("Server reported as running despite startup error, syncing state") logger.warning("Server reported as running despite startup error, syncing state")
isRunning = true isRunning = true
ServerMonitor.shared.isServerRunning = true
} else { } else {
isRunning = false isRunning = false
ServerMonitor.shared.isServerRunning = false
} }
} }
} }
@ -242,14 +215,12 @@ class ServerManager {
return return
} }
let serverType = server.serverType logger.info("Stopping server")
logger.info("Stopping \(serverType.displayName) server")
// Log that we're stopping the server // Log that we're stopping the server
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Stopping \(serverType.displayName) server...", message: "Stopping server..."
source: serverType
)) ))
await server.stop() await server.stop()
@ -261,15 +232,8 @@ class ServerManager {
// Log that the server has stopped // Log that the server has stopped
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "\(serverType.displayName) server stopped", message: "Server stopped"
source: serverType
)) ))
// Update ServerMonitor for compatibility
// 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
@ -281,92 +245,13 @@ class ServerManager {
// Log that we're restarting // Log that we're restarting
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Restarting server...", message: "Restarting server..."
source: serverMode
)) ))
await stop() await stop()
await start() await start()
} }
/// Switch to a different server mode
func switchMode(to mode: ServerMode) async {
guard mode != serverMode else { return }
isSwitching = true
defer { isSwitching = false }
let oldMode = serverMode
logger.info("Switching from \(oldMode.displayName) to \(mode.displayName)")
// Log the mode switch with a clear separator
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "════════════════════════════════════════════════════════",
source: oldMode
))
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Switching server mode: \(oldMode.displayName)\(mode.displayName)",
source: oldMode
))
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "════════════════════════════════════════════════════════",
source: oldMode
))
// Stop current server if running
if currentServer != nil {
await stop()
}
// Add a small delay for visual clarity in logs
try? await Task.sleep(for: .milliseconds(500))
// Update mode
serverMode = mode
// Update VT config file to match
await updateVTConfig(mode: mode)
// Start new server
await start()
// Log completion
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "════════════════════════════════════════════════════════",
source: mode
))
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Server mode switch completed successfully",
source: mode
))
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "════════════════════════════════════════════════════════",
source: mode
))
}
private func handleServerModeChange() async {
// This is called when serverMode changes via AppStorage
// If we have a running server, switch to the new mode
if currentServer != nil {
await switchMode(to: serverMode)
}
}
private func createServer(for mode: ServerMode) -> ServerProtocol {
switch mode {
case .rust:
RustServer()
case .go:
GoServer()
}
}
/// Trigger cleanup of exited sessions after server startup /// Trigger cleanup of exited sessions after server startup
private func triggerInitialCleanup() async { private func triggerInitialCleanup() async {
@ -403,15 +288,13 @@ class ServerManager {
logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions") logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Cleaned up \(cleanedCount) exited sessions on startup", message: "Cleaned up \(cleanedCount) exited sessions on startup"
source: serverMode
)) ))
} else { } else {
logger.info("Initial cleanup completed successfully") logger.info("Initial cleanup completed successfully")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Cleaned up exited sessions on startup", message: "Cleaned up exited sessions on startup"
source: serverMode
)) ))
} }
} else { } else {
@ -423,8 +306,7 @@ class ServerManager {
logger.warning("Failed to trigger initial cleanup: \(error.localizedDescription)") logger.warning("Failed to trigger initial cleanup: \(error.localizedDescription)")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Could not clean up old sessions: \(error.localizedDescription)", message: "Could not clean up old sessions: \(error.localizedDescription)"
source: serverMode
)) ))
} }
} }
@ -440,10 +322,8 @@ class ServerManager {
guard let self else { return } guard let self else { return }
// Only monitor if we're in Rust mode and server should be running // Only monitor if server should be running
guard serverMode == .rust, guard isRunning,
isRunning,
!isSwitching,
!isRestarting else { continue } !isRestarting else { continue }
// Check if server is responding // Check if server is responding
@ -494,8 +374,7 @@ class ServerManager {
logger.error("Server crashed (crash #\(self.crashCount))") logger.error("Server crashed (crash #\(self.crashCount))")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Server crashed unexpectedly (crash #\(self.crashCount))", message: "Server crashed unexpectedly (crash #\(self.crashCount))"
source: serverMode
)) ))
// Clear the current server reference // Clear the current server reference
@ -510,20 +389,18 @@ class ServerManager {
logger.info("Waiting \(delay) seconds before restart attempt...") logger.info("Waiting \(delay) seconds before restart attempt...")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Waiting \(Int(delay)) seconds before restart attempt...", message: "Waiting \(Int(delay)) seconds before restart attempt..."
source: serverMode
)) ))
// Wait with exponential backoff // Wait with exponential backoff
try? await Task.sleep(for: .seconds(delay)) try? await Task.sleep(for: .seconds(delay))
// Attempt to restart // Attempt to restart
if !Task.isCancelled && serverMode == .rust { if !Task.isCancelled {
logger.info("Attempting to restart server after crash...") logger.info("Attempting to restart server after crash...")
logContinuation?.yield(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Attempting automatic restart after crash...", message: "Attempting automatic restart after crash..."
source: serverMode
)) ))
await start() await start()
@ -556,30 +433,6 @@ class ServerManager {
logger.info("Authentication cache clearing requested - handled by external server") logger.info("Authentication cache clearing requested - handled by external server")
} }
/// Update VT config file with the preferred server
private func updateVTConfig(mode: ServerMode) async {
let configPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".vibetunnel")
.appendingPathComponent("config.json")
// Ensure .vibetunnel directory exists
let vibetunnelDir = configPath.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: vibetunnelDir, withIntermediateDirectories: true)
// Prepare config data
let serverValue = mode == .rust ? "rust" : "go" // Map hummingbird to go as well
let config = ["server": serverValue]
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(config)
try data.write(to: configPath)
logger.info("Updated VT config to use \(serverValue) server")
} catch {
logger.error("Failed to update VT config: \(error)")
}
}
} }
// MARK: - Port Conflict Error Extension // MARK: - Port Conflict Error Extension

View file

@ -1,92 +0,0 @@
import Foundation
import Observation
/// 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 {
public static let shared = ServerMonitor()
/// Observable properties
public var isRunning: Bool {
isServerRunning
}
public var port: Int {
Int(ServerManager.shared.port) ?? 4_020
}
public var lastError: Error? {
ServerManager.shared.lastError
}
/// Internal state tracking
public var isServerRunning = false
private init() {
// Sync initial state with ServerManager
Task {
await syncWithServerManager()
}
}
/// Updates the current status from the server
public func updateStatus() {
Task {
await syncWithServerManager()
}
}
/// Syncs state with ServerManager
private func syncWithServerManager() async {
// 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
public func startServer() async throws {
// Delegate to ServerManager
await ServerManager.shared.start()
await syncWithServerManager()
}
/// Stops the server if running
public func stopServer() async throws {
// Delegate to ServerManager
await ServerManager.shared.stop()
await syncWithServerManager()
}
/// Restarts the server
public func restartServer() async throws {
// During restart, we maintain the running state to prevent UI flicker
await ServerManager.shared.restart()
// Sync after restart completes
await syncWithServerManager()
}
/// Checks if the server is healthy by making a health check request
public func checkHealth() async -> Bool {
guard isRunning else { return false }
do {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/health") else {
return false
}
let request = URLRequest(url: url, timeoutInterval: 2.0)
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Server not responding
}
return false
}
}

View file

@ -1,84 +0,0 @@
import Foundation
/// Common interface for server implementations.
///
/// Defines the contract that all VibeTunnel server implementations must follow.
/// This protocol ensures consistent behavior across different server backends
/// (Hummingbird, Rust) while allowing for implementation-specific details.
@MainActor
protocol ServerProtocol: AnyObject {
/// Current running state of the server
var isRunning: Bool { get }
/// Port the server is configured to use
var port: String { get set }
/// Server type identifier
var serverType: ServerMode { get }
/// Start the server
func start() async throws
/// Stop the server
func stop() async
/// Restart the server
func restart() async throws
/// Stream for receiving log messages
var logStream: AsyncStream<ServerLogEntry> { get }
}
/// Server mode options.
///
/// Represents the available server implementations that VibeTunnel can use.
/// Each mode corresponds to a different backend technology with its own
/// performance characteristics and feature set.
enum ServerMode: String, CaseIterable {
case rust
case go
var displayName: String {
switch self {
case .rust:
"Rust"
case .go:
"Go"
}
}
var description: String {
switch self {
case .rust:
"External tty-fwd binary"
case .go:
"External Go binary"
}
}
}
/// Log entry from server.
///
/// Represents a single log message from a server implementation,
/// including severity level, timestamp, and source identification.
struct ServerLogEntry {
/// Severity level of the log entry.
enum Level {
case debug
case info
case warning
case error
}
let timestamp: Date
let level: Level
let message: String
let source: ServerMode
init(level: Level = .info, message: String, source: ServerMode) {
self.timestamp = Date()
self.level = level
self.message = message
self.source = source
}
}

View file

@ -8,28 +8,27 @@ import SwiftUI
struct MenuBarView: View { struct MenuBarView: View {
@Environment(SessionMonitor.self) @Environment(SessionMonitor.self)
var sessionMonitor var sessionMonitor
@Environment(ServerMonitor.self) @State private var serverManager = ServerManager.shared
var serverMonitor
@AppStorage("showInDock") @AppStorage("showInDock")
private var showInDock = false private var showInDock = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Server status header // Server status header
ServerStatusView(isRunning: serverMonitor.isRunning, port: serverMonitor.port) ServerStatusView(isRunning: serverManager.isRunning, port: Int(serverManager.port) ?? 4020)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
// Open Dashboard button // Open Dashboard button
Button(action: { Button(action: {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverMonitor.port)") { if let dashboardURL = URL(string: "http://127.0.0.1:\(serverManager.port)") {
NSWorkspace.shared.open(dashboardURL) NSWorkspace.shared.open(dashboardURL)
} }
}, label: { }, label: {
Label("Open Dashboard", systemImage: "safari") Label("Open Dashboard", systemImage: "safari")
}) })
.buttonStyle(MenuButtonStyle()) .buttonStyle(MenuButtonStyle())
.disabled(!serverMonitor.isRunning) .disabled(!serverManager.isRunning)
Divider() Divider()
.padding(.vertical, 4) .padding(.vertical, 4)

View file

@ -140,15 +140,6 @@ struct ServerLogEntryView: View {
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
.padding(.top, 6) .padding(.top, 6)
// Source badge
Text(entry.source.displayName)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(entry.source.color.opacity(0.2))
.foregroundStyle(entry.source.color)
.clipShape(Capsule())
// Message // Message
Text(entry.message) Text(entry.message)
.textSelection(.enabled) .textSelection(.enabled)
@ -205,8 +196,7 @@ class ServerConsoleViewModel {
let logText = logs.map { entry in let logText = logs.map { entry in
let timestamp = dateFormatter.string(from: entry.timestamp) let timestamp = dateFormatter.string(from: entry.timestamp)
let level = String(describing: entry.level).uppercased().padding(toLength: 7, withPad: " ", startingAt: 0) let level = String(describing: entry.level).uppercased().padding(toLength: 7, withPad: " ", startingAt: 0)
let source = entry.source.displayName.padding(toLength: 12, withPad: " ", startingAt: 0) return "[\(timestamp)] [\(level)] \(entry.message)"
return "[\(timestamp)] [\(level)] [\(source)] \(entry.message)"
} }
.joined(separator: "\n") .joined(separator: "\n")
@ -215,7 +205,7 @@ class ServerConsoleViewModel {
savePanel.nameFieldStringValue = "vibetunnel-server-logs.txt" savePanel.nameFieldStringValue = "vibetunnel-server-logs.txt"
if savePanel.runModal() == .OK, let url = savePanel.url { if savePanel.runModal() == .OK, let url = savePanel.url {
try? logText.write(to: url, atomically: true, encoding: .utf8) try? logText.write(to: url, atomically: true, encoding: String.Encoding.utf8)
} }
} }
} }
@ -248,11 +238,3 @@ extension ServerLogEntry.Level {
} }
} }
extension ServerMode {
var color: Color {
switch self {
case .rust: .orange
case .go: .cyan
}
}
}

View file

@ -49,14 +49,9 @@ struct DashboardSettingsView: View {
static func updateServerForPasswordChange(action: PasswordAction, logger: Logger) async { static func updateServerForPasswordChange(action: PasswordAction, logger: Logger) async {
let serverManager = ServerManager.shared let serverManager = ServerManager.shared
if serverManager.serverMode == .rust { // Go server handles authentication internally
// Rust server requires restart to apply password changes logger.info("Clearing auth cache to \(action.logMessage)")
logger.info("Restarting Rust server to \(action.logMessage)") await serverManager.clearAuthCache()
await serverManager.restart()
} else {
// Hummingbird server just needs cache clear
await serverManager.clearAuthCache()
}
} }
enum PasswordAction { enum PasswordAction {

View file

@ -4,13 +4,10 @@ import SwiftUI
/// Debug settings tab for development and troubleshooting /// Debug settings tab for development and troubleshooting
struct DebugSettingsView: View { struct DebugSettingsView: View {
@State private var serverMonitor = ServerMonitor.shared
@AppStorage("debugMode") @AppStorage("debugMode")
private var debugMode = false private var debugMode = false
@AppStorage("logLevel") @AppStorage("logLevel")
private var logLevel = "info" private var logLevel = "info"
@AppStorage("serverMode")
private var serverModeString = ServerMode.rust.rawValue
@State private var serverManager = ServerManager.shared @State private var serverManager = ServerManager.shared
@State private var isServerHealthy = false @State private var isServerHealthy = false
@State private var heartbeatTask: Task<Void, Never>? @State private var heartbeatTask: Task<Void, Never>?
@ -19,11 +16,11 @@ struct DebugSettingsView: View {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DebugSettings") private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DebugSettings")
private var isServerRunning: Bool { private var isServerRunning: Bool {
serverMonitor.isRunning serverManager.isRunning
} }
private var serverPort: Int { private var serverPort: Int {
serverMonitor.port Int(serverManager.port) ?? 4020
} }
var body: some View { var body: some View {
@ -33,7 +30,6 @@ struct DebugSettingsView: View {
isServerHealthy: isServerHealthy, isServerHealthy: isServerHealthy,
isServerRunning: isServerRunning, isServerRunning: isServerRunning,
serverPort: serverPort, serverPort: serverPort,
serverModeString: $serverModeString,
serverManager: serverManager, serverManager: serverManager,
getCurrentServerMode: getCurrentServerMode getCurrentServerMode: getCurrentServerMode
) )
@ -54,8 +50,6 @@ struct DebugSettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.navigationTitle("Debug Settings") .navigationTitle("Debug Settings")
.onAppear { .onAppear {
// Ensure ServerMonitor is synced with ServerManager
serverMonitor.updateStatus()
// Start heartbeat monitoring // Start heartbeat monitoring
startHeartbeatMonitoring() startHeartbeatMonitoring()
} }
@ -68,10 +62,6 @@ struct DebugSettingsView: View {
// Restart heartbeat monitoring when server state changes // Restart heartbeat monitoring when server state changes
startHeartbeatMonitoring() startHeartbeatMonitoring()
} }
.onChange(of: serverModeString) { _, _ in
// Clear health status when switching modes
isServerHealthy = false
}
.alert("Purge All User Defaults?", isPresented: $showPurgeConfirmation) { .alert("Purge All User Defaults?", isPresented: $showPurgeConfirmation) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Purge", role: .destructive) { Button("Purge", role: .destructive) {
@ -151,13 +141,8 @@ struct DebugSettingsView: View {
} }
private func getCurrentServerMode() -> String { private func getCurrentServerMode() -> String {
// If server is switching, show transitioning state // Server mode is fixed to Go
if serverManager.isSwitching { return "Go"
return "Switching..."
}
// Always use the configured mode from settings to ensure immediate UI update
return ServerMode(rawValue: serverModeString)?.displayName ?? "None"
} }
private func openConsole() { private func openConsole() {
@ -199,7 +184,6 @@ private struct ServerSection: View {
let isServerHealthy: Bool let isServerHealthy: Bool
let isServerRunning: Bool let isServerRunning: Bool
let serverPort: Int let serverPort: Int
@Binding var serverModeString: String
let serverManager: ServerManager let serverManager: ServerManager
let getCurrentServerMode: () -> String let getCurrentServerMode: () -> String
@ -281,49 +265,6 @@ private struct ServerSection: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
Divider()
// Server Mode Configuration
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Server Mode")
Text("Multiple server implementations cause reasons™.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Picker("", selection: Binding(
get: { ServerMode(rawValue: serverModeString) ?? .rust },
set: { newMode in
serverModeString = newMode.rawValue
Task {
await serverManager.switchMode(to: newMode)
}
}
)) {
ForEach(ServerMode.allCases, id: \.self) { mode in
VStack(alignment: .leading) {
Text(mode.displayName)
Text(mode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(mode)
}
}
.pickerStyle(.menu)
.labelsHidden()
.disabled(serverManager.isSwitching)
}
// Server mode switching status with consistent height
HStack {
if serverManager.isSwitching {
TextShimmer(text: "Switching server mode...", font: .caption)
.foregroundStyle(.secondary)
}
}
// Port conflict warning // Port conflict warning
if let conflict = portConflict { if let conflict = portConflict {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {

View file

@ -183,7 +183,7 @@ enum Terminal: String, CaseIterable {
case .wezterm: case .wezterm:
// Use unified AppleScript approach for consistency // Use unified AppleScript approach for consistency
.appleScript(script: unifiedAppleScript(for: config)) .appleScript(script: unifiedAppleScript(for: config))
case .kitty: case .kitty:
// Use unified AppleScript approach for consistency // Use unified AppleScript approach for consistency
.appleScript(script: unifiedAppleScript(for: config)) .appleScript(script: unifiedAppleScript(for: config))

View file

@ -9,7 +9,6 @@ struct VibeTunnelApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) @NSApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate var appDelegate
@State private var sessionMonitor = SessionMonitor.shared @State private var sessionMonitor = SessionMonitor.shared
@State private var serverMonitor = ServerMonitor.shared
init() { init() {
// No special initialization needed // No special initialization needed
@ -73,7 +72,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
private(set) var sparkleUpdaterManager: SparkleUpdaterManager? private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private let serverManager = ServerManager.shared private let serverManager = ServerManager.shared
private let sessionMonitor = SessionMonitor.shared private let sessionMonitor = SessionMonitor.shared
private let serverMonitor = ServerMonitor.shared
private let ngrokService = NgrokService.shared private let ngrokService = NgrokService.shared
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate") private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")