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
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 Hummingbird
import OSLog
@ -13,7 +12,7 @@ import OSLog
final class HummingbirdServer: ServerProtocol {
private var tunnelServer: TunnelServer?
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 }
@ -32,8 +31,14 @@ final class HummingbirdServer: ServerProtocol {
}
}
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
let logStream: AsyncStream<ServerLogEntry>
init() {
var localContinuation: AsyncStream<ServerLogEntry>.Continuation?
self.logStream = AsyncStream { continuation in
localContinuation = continuation
}
self.logContinuation = localContinuation
}
func start() async throws {
@ -43,7 +48,7 @@ final class HummingbirdServer: ServerProtocol {
}
logger.info("Starting Hummingbird server on port \(self.port)")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Initializing Hummingbird server...",
source: .hummingbird
@ -58,10 +63,10 @@ final class HummingbirdServer: ServerProtocol {
try await server.start()
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 {
logger.error("Failed to start Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Failed to start: \(error.localizedDescription)",
source: .hummingbird
@ -77,7 +82,7 @@ final class HummingbirdServer: ServerProtocol {
}
logger.info("Stopping Hummingbird server")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Shutting down Hummingbird server...",
source: .hummingbird
@ -88,14 +93,14 @@ final class HummingbirdServer: ServerProtocol {
tunnelServer = nil
logger.info("Hummingbird server stopped")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Hummingbird server shutdown complete",
source: .hummingbird
))
} catch {
logger.error("Error stopping Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Error stopping: \(error.localizedDescription)",
source: .hummingbird
@ -105,7 +110,7 @@ final class HummingbirdServer: ServerProtocol {
func restart() async throws {
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()
try await start()

View file

@ -298,7 +298,7 @@ final class NgrokService: NgrokTunnelProtocol {
}
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")
}

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import OSLog
@ -27,7 +26,7 @@ final class RustServer: ServerProtocol {
private var errorTask: Task<Void, Never>?
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)
/// Actor to handle process operations on background thread.
@ -89,8 +88,14 @@ final class RustServer: ServerProtocol {
}
}
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
let logStream: AsyncStream<ServerLogEntry>
init() {
var localContinuation: AsyncStream<ServerLogEntry>.Continuation?
self.logStream = AsyncStream { continuation in
localContinuation = continuation
}
self.logContinuation = localContinuation
}
func start() async throws {
@ -102,19 +107,19 @@ final class RustServer: ServerProtocol {
guard !port.isEmpty else {
let error = RustServerError.invalidPort
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
}
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
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")
logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
logContinuation?.yield(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error
}
@ -217,7 +222,7 @@ final class RustServer: ServerProtocol {
let errorData = stderrPipe.fileHandleForReading.availableData
if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) {
logger.error("Process stderr: \(errorOutput)")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Process error: \(errorOutput)",
source: .rust
@ -229,15 +234,15 @@ final class RustServer: ServerProtocol {
}
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
let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5)
if isHealthy {
logger.info("Rust server started successfully and is responding")
logSubject.send(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: "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 {
@ -250,7 +255,7 @@ final class RustServer: ServerProtocol {
} else {
// Server process is running but not responding
logger.error("Rust server process started but is not responding to health checks")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Health check failed - server not responding",
source: .rust
@ -268,7 +273,7 @@ final class RustServer: ServerProtocol {
} catch {
isRunning = false
logger.error("Failed to start Rust server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Failed to start: \(error.localizedDescription)",
source: .rust
@ -284,7 +289,7 @@ final class RustServer: ServerProtocol {
}
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
outputTask?.cancel()
@ -302,7 +307,7 @@ final class RustServer: ServerProtocol {
// Force kill if termination timeout
process.interrupt()
logger.warning("Force killed Rust server after timeout")
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .warning,
message: "Force killed server after timeout",
source: .rust
@ -318,12 +323,12 @@ final class RustServer: ServerProtocol {
isRunning = false
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 {
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()
try await start()
@ -342,7 +347,7 @@ final class RustServer: ServerProtocol {
var request = URLRequest(url: healthURL)
request.timeoutInterval = 2.0
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .debug,
message: "Health check attempt \(attempt)/\(maxAttempts)...",
source: .rust
@ -357,7 +362,7 @@ final class RustServer: ServerProtocol {
} catch {
logger.debug("Health check attempt \(attempt) failed: \(error.localizedDescription)")
if attempt == maxAttempts {
logSubject.send(ServerLogEntry(
logContinuation?.yield(ServerLogEntry(
level: .warning,
message: "Health check failed after \(maxAttempts) attempts",
source: .rust
@ -403,7 +408,7 @@ final class RustServer: ServerProtocol {
Task { @MainActor [weak self] in
guard let self else { return }
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
guard let self else { return }
self.logSubject.send(ServerLogEntry(
self.logContinuation?.yield(ServerLogEntry(
level: .error,
message: line,
source: .rust
@ -464,7 +469,7 @@ final class RustServer: ServerProtocol {
// Unexpected termination
let exitCode = process.terminationStatus
self.logger.error("Rust server terminated unexpectedly with exit code: \(exitCode)")
self.logSubject.send(ServerLogEntry(
self.logContinuation?.yield(ServerLogEntry(
level: .error,
message: "Server terminated unexpectedly with exit code: \(exitCode)",
source: .rust
@ -477,7 +482,7 @@ final class RustServer: ServerProtocol {
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.logSubject.send(ServerLogEntry(
self.logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Auto-restarting server after crash",
source: .rust

View file

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

View file

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

View file

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

View file

@ -395,7 +395,7 @@ public final class TunnelServer {
// Wait for the server to actually start listening
var serverStarted = false
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
if await isServerListening(on: port) {
@ -769,7 +769,7 @@ public final class TunnelServer {
// Wait up to 3 seconds for session ID
let maxAttempts = 30
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
if !moreData.isEmpty {
@ -975,7 +975,7 @@ public final class TunnelServer {
// Send heartbeat every 15 seconds to keep connection alive
while !Task.isCancelled {
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 ':')
var heartbeat = ByteBuffer()

View file

@ -219,8 +219,11 @@ struct DebugSettingsView: View {
UserDefaults.standard.synchronize()
// Quit the app after a short delay to ensure the purge completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
NSApplication.shared.terminate(nil)
Task {
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.