mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
356 lines
12 KiB
Swift
356 lines
12 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OSLog
|
|
import SwiftUI
|
|
|
|
/// Manages the active server and handles switching between modes.
|
|
///
|
|
/// `ServerManager` is the central coordinator for server lifecycle management in VibeTunnel.
|
|
/// It handles starting, stopping, and switching between different server implementations (Rust/Hummingbird),
|
|
/// manages server configuration, and provides logging capabilities. The manager ensures only one
|
|
/// server instance runs at a time and coordinates smooth transitions between server modes.
|
|
@MainActor
|
|
@Observable
|
|
class ServerManager {
|
|
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 {
|
|
get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" }
|
|
set { UserDefaults.standard.set(newValue, forKey: "serverPort") }
|
|
}
|
|
|
|
var bindAddress: String {
|
|
get {
|
|
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
|
|
) ??
|
|
.localhost
|
|
return mode.bindAddress
|
|
}
|
|
set {
|
|
// Find the mode that matches this bind address
|
|
if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) {
|
|
UserDefaults.standard.set(mode.rawValue, forKey: "dashboardAccessMode")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var cleanupOnStartup: Bool {
|
|
get { UserDefaults.standard.bool(forKey: "cleanupOnStartup") }
|
|
set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") }
|
|
}
|
|
|
|
private(set) var currentServer: ServerProtocol?
|
|
private(set) var isRunning = false
|
|
private(set) var isSwitching = false
|
|
private(set) var isRestarting = false
|
|
private(set) var lastError: Error?
|
|
|
|
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager")
|
|
private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
|
|
private var serverLogTask: Task<Void, Never>?
|
|
private(set) var logStream: AsyncStream<ServerLogEntry>!
|
|
|
|
var serverMode: ServerMode {
|
|
get { ServerMode(rawValue: serverModeString) ?? .rust }
|
|
set { serverModeString = newValue.rawValue }
|
|
}
|
|
|
|
private init() {
|
|
setupLogStream()
|
|
setupObservers()
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
private func setupLogStream() {
|
|
logStream = AsyncStream { continuation in
|
|
self.logContinuation = continuation
|
|
}
|
|
}
|
|
|
|
private func setupObservers() {
|
|
// Watch for server mode changes when the value actually changes
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(userDefaultsDidChange),
|
|
name: UserDefaults.didChangeNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@objc private func userDefaultsDidChange() {
|
|
Task { @MainActor in
|
|
await handleServerModeChange()
|
|
}
|
|
}
|
|
|
|
/// Start the server with current configuration
|
|
func start() async {
|
|
// Check if we already have a running server
|
|
if let existingServer = currentServer {
|
|
logger.info("Server already running on port \(existingServer.port)")
|
|
|
|
// Ensure our state is synced
|
|
isRunning = true
|
|
lastError = nil
|
|
ServerMonitor.shared.isServerRunning = true
|
|
|
|
// Log for clarity
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "\(serverMode.displayName) server already running on port \(port)",
|
|
source: serverMode
|
|
))
|
|
return
|
|
}
|
|
|
|
// Log that we're starting a server
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "Starting \(serverMode.displayName) server on port \(port)...",
|
|
source: serverMode
|
|
))
|
|
|
|
do {
|
|
let server = createServer(for: serverMode)
|
|
server.port = port
|
|
|
|
// Subscribe to server logs
|
|
serverLogTask = Task { [weak self] in
|
|
for await entry in server.logStream {
|
|
self?.logContinuation?.yield(entry)
|
|
}
|
|
}
|
|
|
|
try await server.start()
|
|
|
|
currentServer = server
|
|
isRunning = true
|
|
lastError = nil
|
|
|
|
logger.info("Started \(self.serverMode.displayName) server on port \(self.port)")
|
|
|
|
// Update ServerMonitor for compatibility
|
|
ServerMonitor.shared.isServerRunning = true
|
|
|
|
// Trigger cleanup of old sessions after server starts
|
|
await triggerInitialCleanup()
|
|
} catch {
|
|
logger.error("Failed to start server: \(error.localizedDescription)")
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .error,
|
|
message: "Failed to start \(serverMode.displayName) server: \(error.localizedDescription)",
|
|
source: serverMode
|
|
))
|
|
lastError = error
|
|
|
|
// Check if server is actually running despite the error
|
|
if let server = currentServer, server.isRunning {
|
|
logger.warning("Server reported as running despite startup error, syncing state")
|
|
isRunning = true
|
|
ServerMonitor.shared.isServerRunning = true
|
|
} else {
|
|
isRunning = false
|
|
ServerMonitor.shared.isServerRunning = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop the current server
|
|
func stop() async {
|
|
guard let server = currentServer else {
|
|
logger.warning("No server running")
|
|
return
|
|
}
|
|
|
|
let serverType = server.serverType
|
|
logger.info("Stopping \(serverType.displayName) server")
|
|
|
|
// Log that we're stopping the server
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "Stopping \(serverType.displayName) server...",
|
|
source: serverType
|
|
))
|
|
|
|
await server.stop()
|
|
serverLogTask?.cancel()
|
|
serverLogTask = nil
|
|
currentServer = nil
|
|
isRunning = false
|
|
|
|
// Log that the server has stopped
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "\(serverType.displayName) 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
|
|
func restart() async {
|
|
// Set restarting flag to prevent UI from showing "stopped" state
|
|
isRestarting = true
|
|
defer { isRestarting = false }
|
|
|
|
// Log that we're restarting
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "Restarting server...",
|
|
source: serverMode
|
|
))
|
|
|
|
await stop()
|
|
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
|
|
|
|
// 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 .hummingbird:
|
|
HummingbirdServer()
|
|
case .rust:
|
|
RustServer()
|
|
}
|
|
}
|
|
|
|
/// Trigger cleanup of exited sessions after server startup
|
|
private func triggerInitialCleanup() async {
|
|
// Check if cleanup on startup is enabled
|
|
guard cleanupOnStartup else {
|
|
logger.info("Cleanup on startup is disabled in settings")
|
|
return
|
|
}
|
|
|
|
logger.info("Triggering initial cleanup of exited sessions")
|
|
|
|
// Small delay to ensure server is fully ready
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
|
|
do {
|
|
// Create URL for cleanup endpoint
|
|
guard let url = URL(string: "http://localhost:\(port)/api/cleanup-exited") else {
|
|
logger.warning("Failed to create cleanup URL")
|
|
return
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 10
|
|
|
|
// Make the cleanup request
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
if httpResponse.statusCode == 200 {
|
|
// Try to parse the response
|
|
if let jsonData = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let cleanedCount = jsonData["cleaned_count"] as? Int
|
|
{
|
|
logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions")
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "Cleaned up \(cleanedCount) exited sessions on startup",
|
|
source: serverMode
|
|
))
|
|
} else {
|
|
logger.info("Initial cleanup completed successfully")
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .info,
|
|
message: "Cleaned up exited sessions on startup",
|
|
source: serverMode
|
|
))
|
|
}
|
|
} else {
|
|
logger.warning("Initial cleanup returned status code: \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
} catch {
|
|
// Log the error but don't fail startup
|
|
logger.warning("Failed to trigger initial cleanup: \(error.localizedDescription)")
|
|
logContinuation?.yield(ServerLogEntry(
|
|
level: .warning,
|
|
message: "Could not clean up old sessions: \(error.localizedDescription)",
|
|
source: serverMode
|
|
))
|
|
}
|
|
}
|
|
}
|