Use modern Swift

This commit is contained in:
Peter Steinberger 2025-06-17 02:17:52 +02:00
parent 831361f5b2
commit 7b02733207
10 changed files with 92 additions and 184 deletions

View file

@ -219,7 +219,7 @@ struct CastFileGenerator {
} }
// Sleep briefly before checking again // Sleep briefly before checking again
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds try? await Task.sleep(for: .milliseconds(100))
} }
} }
} }

View file

@ -1,4 +1,3 @@
import Combine
import Foundation import Foundation
import Hummingbird import Hummingbird
import OSLog import OSLog
@ -13,7 +12,7 @@ import OSLog
final class HummingbirdServer: ServerProtocol { final class HummingbirdServer: ServerProtocol {
private var tunnelServer: TunnelServer? private var tunnelServer: TunnelServer?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "HummingbirdServer") private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "HummingbirdServer")
private let logSubject = PassthroughSubject<ServerLogEntry, Never>() private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
var serverType: ServerMode { .hummingbird } var serverType: ServerMode { .hummingbird }
@ -32,8 +31,14 @@ final class HummingbirdServer: ServerProtocol {
} }
} }
var logPublisher: AnyPublisher<ServerLogEntry, Never> { let logStream: AsyncStream<ServerLogEntry>
logSubject.eraseToAnyPublisher()
init() {
var localContinuation: AsyncStream<ServerLogEntry>.Continuation?
self.logStream = AsyncStream { continuation in
localContinuation = continuation
}
self.logContinuation = localContinuation
} }
func start() async throws { func start() async throws {
@ -43,7 +48,7 @@ final class HummingbirdServer: ServerProtocol {
} }
logger.info("Starting Hummingbird server on port \(self.port)") logger.info("Starting Hummingbird server on port \(self.port)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Initializing Hummingbird server...", message: "Initializing Hummingbird server...",
source: .hummingbird source: .hummingbird
@ -58,10 +63,10 @@ final class HummingbirdServer: ServerProtocol {
try await server.start() try await server.start()
logger.info("Hummingbird server started successfully") logger.info("Hummingbird server started successfully")
logSubject.send(ServerLogEntry(level: .info, message: "Hummingbird server is ready", source: .hummingbird)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Hummingbird server is ready", source: .hummingbird))
} catch { } catch {
logger.error("Failed to start Hummingbird server: \(error.localizedDescription)") logger.error("Failed to start Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to start: \(error.localizedDescription)", message: "Failed to start: \(error.localizedDescription)",
source: .hummingbird source: .hummingbird
@ -77,7 +82,7 @@ final class HummingbirdServer: ServerProtocol {
} }
logger.info("Stopping Hummingbird server") logger.info("Stopping Hummingbird server")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Shutting down Hummingbird server...", message: "Shutting down Hummingbird server...",
source: .hummingbird source: .hummingbird
@ -88,14 +93,14 @@ final class HummingbirdServer: ServerProtocol {
tunnelServer = nil tunnelServer = nil
logger.info("Hummingbird server stopped") logger.info("Hummingbird server stopped")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Hummingbird server shutdown complete", message: "Hummingbird server shutdown complete",
source: .hummingbird source: .hummingbird
)) ))
} catch { } catch {
logger.error("Error stopping Hummingbird server: \(error.localizedDescription)") logger.error("Error stopping Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Error stopping: \(error.localizedDescription)", message: "Error stopping: \(error.localizedDescription)",
source: .hummingbird source: .hummingbird
@ -105,7 +110,7 @@ final class HummingbirdServer: ServerProtocol {
func restart() async throws { func restart() async throws {
logger.info("Restarting Hummingbird server") logger.info("Restarting Hummingbird server")
logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .hummingbird)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Restarting server", source: .hummingbird))
await stop() await stop()
try await start() try await start()

View file

@ -298,7 +298,7 @@ final class NgrokService: NgrokTunnelProtocol {
} }
group.addTask { group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) try await Task.sleep(for: .seconds(seconds))
throw NgrokError.networkError("Operation timed out") throw NgrokError.networkError("Operation timed out")
} }

View file

@ -1,4 +1,3 @@
import Combine
import Foundation import Foundation
import OSLog import OSLog
@ -27,7 +26,7 @@ final class RustServer: ServerProtocol {
private var errorTask: Task<Void, Never>? private var errorTask: Task<Void, Never>?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "RustServer") private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "RustServer")
private let logSubject = PassthroughSubject<ServerLogEntry, Never>() private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
private let processQueue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer", qos: .userInitiated) private let processQueue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer", qos: .userInitiated)
/// Actor to handle process operations on background thread. /// Actor to handle process operations on background thread.
@ -89,8 +88,14 @@ final class RustServer: ServerProtocol {
} }
} }
var logPublisher: AnyPublisher<ServerLogEntry, Never> { let logStream: AsyncStream<ServerLogEntry>
logSubject.eraseToAnyPublisher()
init() {
var localContinuation: AsyncStream<ServerLogEntry>.Continuation?
self.logStream = AsyncStream { continuation in
localContinuation = continuation
}
self.logContinuation = localContinuation
} }
func start() async throws { func start() async throws {
@ -102,19 +107,19 @@ final class RustServer: ServerProtocol {
guard !port.isEmpty else { guard !port.isEmpty else {
let error = RustServerError.invalidPort let error = RustServerError.invalidPort
logger.error("Port not configured") logger.error("Port not configured")
logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust)) logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error throw error
} }
logger.info("Starting Rust tty-fwd server on port \(self.port)") logger.info("Starting Rust tty-fwd server on port \(self.port)")
logSubject.send(ServerLogEntry(level: .info, message: "Initializing Rust tty-fwd server...", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Initializing Rust tty-fwd server...", source: .rust))
// Get the tty-fwd binary path // Get the tty-fwd binary path
let binaryPath = Bundle.main.path(forResource: "tty-fwd", ofType: nil) let binaryPath = Bundle.main.path(forResource: "tty-fwd", ofType: nil)
guard let binaryPath else { guard let binaryPath else {
let error = RustServerError.binaryNotFound let error = RustServerError.binaryNotFound
logger.error("tty-fwd binary not found in bundle") logger.error("tty-fwd binary not found in bundle")
logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust)) logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error throw error
} }
@ -217,7 +222,7 @@ final class RustServer: ServerProtocol {
let errorData = stderrPipe.fileHandleForReading.availableData let errorData = stderrPipe.fileHandleForReading.availableData
if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) { if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) {
logger.error("Process stderr: \(errorOutput)") logger.error("Process stderr: \(errorOutput)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Process error: \(errorOutput)", message: "Process error: \(errorOutput)",
source: .rust source: .rust
@ -229,15 +234,15 @@ final class RustServer: ServerProtocol {
} }
logger.info("Rust server process started, performing health check...") logger.info("Rust server process started, performing health check...")
logSubject.send(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust))
// 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("Rust server started successfully and is responding") logger.info("Rust server started successfully and is responding")
logSubject.send(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .rust))
logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server is ready", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Rust tty-fwd server is ready", source: .rust))
// Monitor process termination with task context // Monitor process termination with task context
Task { Task {
@ -250,7 +255,7 @@ final class RustServer: ServerProtocol {
} else { } else {
// Server process is running but not responding // Server process is running but not responding
logger.error("Rust server process started but is not responding to health checks") logger.error("Rust server process started but is not responding to health checks")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Health check failed - server not responding", message: "Health check failed - server not responding",
source: .rust source: .rust
@ -268,7 +273,7 @@ final class RustServer: ServerProtocol {
} catch { } catch {
isRunning = false isRunning = false
logger.error("Failed to start Rust server: \(error.localizedDescription)") logger.error("Failed to start Rust server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to start: \(error.localizedDescription)", message: "Failed to start: \(error.localizedDescription)",
source: .rust source: .rust
@ -284,7 +289,7 @@ final class RustServer: ServerProtocol {
} }
logger.info("Stopping Rust server") logger.info("Stopping Rust server")
logSubject.send(ServerLogEntry(level: .info, message: "Shutting down Rust tty-fwd server...", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Shutting down Rust tty-fwd server...", source: .rust))
// Cancel output monitoring tasks // Cancel output monitoring tasks
outputTask?.cancel() outputTask?.cancel()
@ -302,7 +307,7 @@ final class RustServer: ServerProtocol {
// Force kill if termination timeout // Force kill if termination timeout
process.interrupt() process.interrupt()
logger.warning("Force killed Rust server after timeout") logger.warning("Force killed Rust server after timeout")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Force killed server after timeout", message: "Force killed server after timeout",
source: .rust source: .rust
@ -318,12 +323,12 @@ final class RustServer: ServerProtocol {
isRunning = false isRunning = false
logger.info("Rust server stopped") logger.info("Rust server stopped")
logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server shutdown complete", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Rust tty-fwd server shutdown complete", source: .rust))
} }
func restart() async throws { func restart() async throws {
logger.info("Restarting Rust server") logger.info("Restarting Rust server")
logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .rust)) logContinuation?.yield(ServerLogEntry(level: .info, message: "Restarting server", source: .rust))
await stop() await stop()
try await start() try await start()
@ -342,7 +347,7 @@ final class RustServer: ServerProtocol {
var request = URLRequest(url: healthURL) var request = URLRequest(url: healthURL)
request.timeoutInterval = 2.0 request.timeoutInterval = 2.0
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .debug, level: .debug,
message: "Health check attempt \(attempt)/\(maxAttempts)...", message: "Health check attempt \(attempt)/\(maxAttempts)...",
source: .rust source: .rust
@ -357,7 +362,7 @@ final class RustServer: ServerProtocol {
} catch { } catch {
logger.debug("Health check attempt \(attempt) failed: \(error.localizedDescription)") logger.debug("Health check attempt \(attempt) failed: \(error.localizedDescription)")
if attempt == maxAttempts { if attempt == maxAttempts {
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .warning, level: .warning,
message: "Health check failed after \(maxAttempts) attempts", message: "Health check failed after \(maxAttempts) attempts",
source: .rust source: .rust
@ -403,7 +408,7 @@ final class RustServer: ServerProtocol {
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.logSubject.send(ServerLogEntry(level: level, message: line, source: .rust)) self.logContinuation?.yield(ServerLogEntry(level: level, message: line, source: .rust))
} }
} }
} }
@ -437,7 +442,7 @@ final class RustServer: ServerProtocol {
} }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
self.logSubject.send(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: line, message: line,
source: .rust source: .rust
@ -464,7 +469,7 @@ final class RustServer: ServerProtocol {
// Unexpected termination // Unexpected termination
let exitCode = process.terminationStatus let exitCode = process.terminationStatus
self.logger.error("Rust server terminated unexpectedly with exit code: \(exitCode)") self.logger.error("Rust server terminated unexpectedly with exit code: \(exitCode)")
self.logSubject.send(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: .rust source: .rust
@ -477,7 +482,7 @@ final class RustServer: ServerProtocol {
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
if self.process == nil { // Only restart if not manually stopped if self.process == nil { // Only restart if not manually stopped
self.logger.info("Auto-restarting Rust server after crash") self.logger.info("Auto-restarting Rust server after crash")
self.logSubject.send(ServerLogEntry( self.logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Auto-restarting server after crash", message: "Auto-restarting server after crash",
source: .rust source: .rust

View file

@ -1,4 +1,3 @@
import Combine
import Foundation import Foundation
import Observation import Observation
import OSLog import OSLog
@ -52,45 +51,33 @@ class ServerManager {
private(set) var lastError: Error? private(set) var lastError: Error?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager") private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager")
private var cancellables = Set<AnyCancellable>() private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
private let logSubject = PassthroughSubject<ServerLogEntry, Never>() private var serverLogTask: Task<Void, Never>?
private(set) var logStream: AsyncStream<ServerLogEntry>!
var serverMode: ServerMode { var serverMode: ServerMode {
get { ServerMode(rawValue: serverModeString) ?? .rust } get { ServerMode(rawValue: serverModeString) ?? .rust }
set { serverModeString = newValue.rawValue } set { serverModeString = newValue.rawValue }
} }
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
}
/// Modern async stream for logs
var logStream: AsyncStream<ServerLogEntry> {
AsyncStream { continuation in
// Use logPublisher directly without storing the cancellable
Task { @MainActor in
for await entry in logPublisher.values {
continuation.yield(entry)
}
continuation.finish()
}
}
}
private init() { private init() {
setupLogStream()
setupObservers() setupObservers()
} }
private func setupLogStream() {
logStream = AsyncStream { continuation in
self.logContinuation = continuation
}
}
private func setupObservers() { private func setupObservers() {
// Watch for server mode changes when the value actually changes // Watch for server mode changes when the value actually changes
// Since we're using @AppStorage, we need to observe changes differently Task { @MainActor in
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) for await _ in NotificationCenter.default.notifications(named: UserDefaults.didChangeNotification) {
.sink { [weak self] _ in await handleServerModeChange()
Task { @MainActor [weak self] in
await self?.handleServerModeChange()
}
} }
.store(in: &cancellables) }
} }
/// Start the server with current configuration /// Start the server with current configuration
@ -105,7 +92,7 @@ class ServerManager {
ServerMonitor.shared.isServerRunning = true ServerMonitor.shared.isServerRunning = true
// Log for clarity // Log for clarity
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "\(serverMode.displayName) server already running on port \(port)", message: "\(serverMode.displayName) server already running on port \(port)",
source: serverMode source: serverMode
@ -114,7 +101,7 @@ class ServerManager {
} }
// Log that we're starting a server // Log that we're starting a server
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Starting \(serverMode.displayName) server on port \(port)...", message: "Starting \(serverMode.displayName) server on port \(port)...",
source: serverMode source: serverMode
@ -125,11 +112,11 @@ class ServerManager {
server.port = port server.port = port
// Subscribe to server logs // Subscribe to server logs
server.logPublisher serverLogTask = Task { [weak self] in
.sink { [weak self] entry in for await entry in server.logStream {
self?.logSubject.send(entry) self?.logContinuation?.yield(entry)
} }
.store(in: &cancellables) }
try await server.start() try await server.start()
@ -146,7 +133,7 @@ class ServerManager {
await triggerInitialCleanup() await triggerInitialCleanup()
} catch { } catch {
logger.error("Failed to start server: \(error.localizedDescription)") logger.error("Failed to start server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .error, level: .error,
message: "Failed to start \(serverMode.displayName) server: \(error.localizedDescription)", message: "Failed to start \(serverMode.displayName) server: \(error.localizedDescription)",
source: serverMode source: serverMode
@ -176,18 +163,20 @@ class ServerManager {
logger.info("Stopping \(serverType.displayName) server") logger.info("Stopping \(serverType.displayName) server")
// Log that we're stopping the server // Log that we're stopping the server
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Stopping \(serverType.displayName) server...", message: "Stopping \(serverType.displayName) server...",
source: serverType source: serverType
)) ))
await server.stop() await server.stop()
serverLogTask?.cancel()
serverLogTask = nil
currentServer = nil currentServer = nil
isRunning = false isRunning = false
// Log that the server has stopped // Log that the server has stopped
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "\(serverType.displayName) server stopped", message: "\(serverType.displayName) server stopped",
source: serverType source: serverType
@ -207,7 +196,7 @@ class ServerManager {
defer { isRestarting = false } defer { isRestarting = false }
// Log that we're restarting // Log that we're restarting
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Restarting server...", message: "Restarting server...",
source: serverMode source: serverMode
@ -228,17 +217,17 @@ class ServerManager {
logger.info("Switching from \(oldMode.displayName) to \(mode.displayName)") logger.info("Switching from \(oldMode.displayName) to \(mode.displayName)")
// Log the mode switch with a clear separator // Log the mode switch with a clear separator
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "════════════════════════════════════════════════════════", message: "════════════════════════════════════════════════════════",
source: oldMode source: oldMode
)) ))
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Switching server mode: \(oldMode.displayName)\(mode.displayName)", message: "Switching server mode: \(oldMode.displayName)\(mode.displayName)",
source: oldMode source: oldMode
)) ))
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "════════════════════════════════════════════════════════", message: "════════════════════════════════════════════════════════",
source: oldMode source: oldMode
@ -259,17 +248,17 @@ class ServerManager {
await start() await start()
// Log completion // Log completion
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "════════════════════════════════════════════════════════", message: "════════════════════════════════════════════════════════",
source: mode source: mode
)) ))
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Server mode switch completed successfully", message: "Server mode switch completed successfully",
source: mode source: mode
)) ))
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "════════════════════════════════════════════════════════", message: "════════════════════════════════════════════════════════",
source: mode source: mode
@ -326,14 +315,14 @@ class ServerManager {
let cleanedCount = jsonData["cleaned_count"] as? Int let cleanedCount = jsonData["cleaned_count"] as? Int
{ {
logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions") logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions")
logSubject.send(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 source: serverMode
)) ))
} else { } else {
logger.info("Initial cleanup completed successfully") logger.info("Initial cleanup completed successfully")
logSubject.send(ServerLogEntry( logContinuation?.yield(ServerLogEntry(
level: .info, level: .info,
message: "Cleaned up exited sessions on startup", message: "Cleaned up exited sessions on startup",
source: serverMode source: serverMode
@ -346,7 +335,7 @@ class ServerManager {
} catch { } catch {
// Log the error but don't fail startup // Log the error but don't fail startup
logger.warning("Failed to trigger initial cleanup: \(error.localizedDescription)") logger.warning("Failed to trigger initial cleanup: \(error.localizedDescription)")
logSubject.send(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 source: serverMode

View file

@ -1,4 +1,3 @@
import Combine
import Foundation import Foundation
/// Common interface for server implementations. /// Common interface for server implementations.
@ -26,8 +25,8 @@ protocol ServerProtocol: AnyObject {
/// Restart the server /// Restart the server
func restart() async throws func restart() async throws
/// Publisher for streaming log messages /// Stream for receiving log messages
var logPublisher: AnyPublisher<ServerLogEntry, Never> { get } var logStream: AsyncStream<ServerLogEntry> { get }
} }
/// Server mode options. /// Server mode options.

View file

@ -143,7 +143,7 @@ actor TerminalManager {
} }
group.addTask { group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) try await Task.sleep(for: .seconds(seconds))
throw TunnelError.timeout throw TunnelError.timeout
} }

View file

@ -395,7 +395,7 @@ public final class TunnelServer {
// Wait for the server to actually start listening // Wait for the server to actually start listening
var serverStarted = false var serverStarted = false
for _ in 0..<10 { // Try for up to 1 second for _ in 0..<10 { // Try for up to 1 second
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds try await Task.sleep(for: .milliseconds(100))
// Check if the server is actually listening // Check if the server is actually listening
if await isServerListening(on: port) { if await isServerListening(on: port) {
@ -769,7 +769,7 @@ public final class TunnelServer {
// Wait up to 3 seconds for session ID // Wait up to 3 seconds for session ID
let maxAttempts = 30 let maxAttempts = 30
for _ in 0..<maxAttempts { for _ in 0..<maxAttempts {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds try await Task.sleep(for: .milliseconds(100))
let moreData = outputPipe.fileHandleForReading.availableData let moreData = outputPipe.fileHandleForReading.availableData
if !moreData.isEmpty { if !moreData.isEmpty {
@ -975,7 +975,7 @@ public final class TunnelServer {
// Send heartbeat every 15 seconds to keep connection alive // Send heartbeat every 15 seconds to keep connection alive
while !Task.isCancelled { while !Task.isCancelled {
do { do {
try await Task.sleep(nanoseconds: 15_000_000_000) // 15 seconds try await Task.sleep(for: .seconds(15))
// Send SSE comment as heartbeat (comments start with ':') // Send SSE comment as heartbeat (comments start with ':')
var heartbeat = ByteBuffer() var heartbeat = ByteBuffer()

View file

@ -219,8 +219,11 @@ struct DebugSettingsView: View {
UserDefaults.standard.synchronize() UserDefaults.standard.synchronize()
// Quit the app after a short delay to ensure the purge completes // Quit the app after a short delay to ensure the purge completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task {
NSApplication.shared.terminate(nil) try? await Task.sleep(for: .milliseconds(500))
await MainActor.run {
NSApplication.shared.terminate(nil)
}
} }
} }
} }

View file

@ -1,93 +0,0 @@
# Modern Swift Refactoring Summary
This document summarizes the modernization changes made to the VibeTunnel codebase to align with modern Swift best practices as outlined in `modern-swift.md`.
## Key Changes
### 1. Converted @ObservableObject to @Observable
- **SessionMonitor.swift**: Converted from `@ObservableObject` with `@Published` properties to `@Observable` class
- Removed `import Combine`
- Replaced `@Published` with regular properties
- Changed from `ObservableObject` to `@Observable`
- **TunnelServerDemo.swift**: Converted from `@ObservableObject` to `@Observable`
- Removed `import Combine`
- Simplified property declarations
- **SparkleViewModel**: Converted stub implementation to use `@Observable`
### 2. Replaced Combine with Async/Await
- **SessionMonitor.swift**:
- Replaced `Timer` with `Task` for periodic monitoring
- Used `Task.sleep(for:)` instead of Timer callbacks
- Eliminated nested `Task { }` blocks
- **TunnelClient.swift**:
- Removed `PassthroughSubject` from WebSocket implementation
- Replaced with `AsyncStream<WSMessage>` for message handling
- Updated delegate methods to use `continuation.yield()` instead of `subject.send()`
### 3. Simplified State Management
- **VibeTunnelApp.swift**:
- Changed `@StateObject` to `@State` for SessionMonitor
- Updated `.environmentObject()` to `.environment()` for modern environment injection
- **MenuBarView.swift**:
- Changed `@EnvironmentObject` to `@Environment(SessionMonitor.self)`
- **SettingsView.swift**:
- Removed `ServerObserver` ViewModel completely
- Moved server state directly into view as `@State private var httpServer`
- Simplified server state access with computed properties
### 4. Modernized Async Operations
- **VibeTunnelApp.swift**:
- Replaced `DispatchQueue.main.asyncAfter` with `Task.sleep(for:)`
- Updated all `Task.sleep(nanoseconds:)` to `Task.sleep(for:)` with Duration
- **SettingsView.swift**:
- Replaced `.onAppear` with `.task` for async initialization
- Modernized Task.sleep usage throughout
### 5. Removed Unnecessary Abstractions
- Eliminated `ServerObserver` class - moved logic directly into views
- Removed Combine imports where no longer needed
- Simplified state ownership by keeping it close to where it's used
### 6. Updated Error Handling
- Maintained proper async/await error handling with try/catch
- Removed completion handler patterns where applicable
- Simplified error state management
## Benefits Achieved
1. **Reduced Dependencies**: Removed Combine dependency from most files
2. **Simpler Code**: Eliminated unnecessary ViewModels and abstractions
3. **Better Performance**: Native SwiftUI state management with @Observable
4. **Modern Patterns**: Consistent use of async/await throughout
5. **Cleaner Architecture**: State lives closer to where it's used
## Migration Notes
- All @Observable classes require iOS 17+ / macOS 14+
- AsyncStream provides a more natural API than Combine subjects
- Task-based monitoring is more efficient than Timer-based
- SwiftUI's built-in state management eliminates need for custom ViewModels
## Files Modified
1. `/VibeTunnel/Core/Services/SessionMonitor.swift`
2. `/VibeTunnel/Core/Services/TunnelClient.swift`
3. `/VibeTunnel/Core/Services/TunnelServerDemo.swift`
4. `/VibeTunnel/Core/Services/SparkleUpdaterManager.swift`
5. `/VibeTunnel/VibeTunnelApp.swift`
6. `/VibeTunnel/Views/MenuBarView.swift`
7. `/VibeTunnel/SettingsView.swift`
All changes follow the principles outlined in the Modern Swift guidelines, embracing SwiftUI's native patterns and avoiding unnecessary complexity.