mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Simplify Mac app now that have ONE TRUE SERVER
This commit is contained in:
parent
ce1dd762e9
commit
e1afae29c8
11 changed files with 132 additions and 1179 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue