diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index 096121bd..f10529a0 100644 Binary files a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate and b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index 1ac0c34b..86444224 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -1,4 +1,5 @@ import Foundation +import Observation import os.log import UserNotifications @@ -39,14 +40,15 @@ public final class SparkleUpdaterManager: NSObject { /// Stub implementation of SparkleViewModel @MainActor @available(macOS 10.15, *) -public final class SparkleViewModel: ObservableObject { - @Published public var canCheckForUpdates = false - @Published public var isCheckingForUpdates = false - @Published public var automaticallyChecksForUpdates = true - @Published public var automaticallyDownloadsUpdates = false - @Published public var updateCheckInterval: TimeInterval = 86400 - @Published public var lastUpdateCheckDate: Date? - @Published public var updateChannel: UpdateChannel = .stable +@Observable +public final class SparkleViewModel { + public var canCheckForUpdates = false + public var isCheckingForUpdates = false + public var automaticallyChecksForUpdates = true + public var automaticallyDownloadsUpdates = false + public var updateCheckInterval: TimeInterval = 86400 + public var lastUpdateCheckDate: Date? + public var updateChannel: UpdateChannel = .stable private let updaterManager = SparkleUpdaterManager.shared diff --git a/VibeTunnel/Core/Services/TunnelClient.swift b/VibeTunnel/Core/Services/TunnelClient.swift index de8bc316..eb93fd3e 100644 --- a/VibeTunnel/Core/Services/TunnelClient.swift +++ b/VibeTunnel/Core/Services/TunnelClient.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Logging @@ -202,11 +201,13 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable { private let apiKey: String private var sessionId: String? private var webSocketTask: URLSessionWebSocketTask? - private let messageSubject = PassthroughSubject() + private var messageContinuation: AsyncStream.Continuation? private let logger = Logger(label: "VibeTunnel.TunnelWebSocketClient") - public var messages: AnyPublisher { - messageSubject.eraseToAnyPublisher() + public var messages: AsyncStream { + AsyncStream { continuation in + self.messageContinuation = continuation + } } public init(url: URL, apiKey: String, sessionId: String? = nil) { @@ -258,6 +259,7 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable { public func disconnect() { webSocketTask?.cancel(with: .goingAway, reason: nil) + messageContinuation?.finish() } private func receiveMessage() { @@ -269,11 +271,11 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable { if let data = text.data(using: .utf8), let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) { - self?.messageSubject.send(wsMessage) + self?.messageContinuation?.yield(wsMessage) } case .data(let data): if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) { - self?.messageSubject.send(wsMessage) + self?.messageContinuation?.yield(wsMessage) } @unknown default: break @@ -307,7 +309,7 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate { reason: Data? ) { logger.info("WebSocket disconnected with code: \(closeCode)") - messageSubject.send(completion: .finished) + messageContinuation?.finish() } } diff --git a/VibeTunnel/Core/Services/TunnelServerDemo.swift b/VibeTunnel/Core/Services/TunnelServer.swift similarity index 98% rename from VibeTunnel/Core/Services/TunnelServerDemo.swift rename to VibeTunnel/Core/Services/TunnelServer.swift index e6ea2917..dac6c839 100644 --- a/VibeTunnel/Core/Services/TunnelServerDemo.swift +++ b/VibeTunnel/Core/Services/TunnelServer.swift @@ -22,7 +22,7 @@ enum ServerError: LocalizedError { /// HTTP server implementation for the macOS app @MainActor @Observable -public final class TunnelServerDemo { +public final class TunnelServer { public private(set) var isRunning = false public private(set) var port: Int public var lastError: Error? @@ -39,7 +39,7 @@ public final class TunnelServerDemo { public func start() async throws { guard !isRunning else { return } - logger.info("Starting TunnelServerDemo on port \(port)") + logger.info("Starting TunnelServer on port \(port)") do { let router = Router(context: BasicRequestContext.self) diff --git a/VibeTunnel/Core/Services/TunnelServerExample.swift b/VibeTunnel/Core/Services/TunnelServerExample.swift deleted file mode 100644 index 82efe626..00000000 --- a/VibeTunnel/Core/Services/TunnelServerExample.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Combine -import Foundation -import Logging - -/// Demo code showing how to use the VibeTunnel server -enum TunnelServerExample { - private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo") - - static func runDemo() async { - // Get the API key (in production, this should be managed securely) - // For demo purposes, using a hardcoded key - let apiKey = "demo-api-key-12345" - - logger.info("Using API key: [REDACTED]") - - // Create client - let client = TunnelClient(apiKey: apiKey) - - do { - // Check server health - let isHealthy = try await client.checkHealth() - logger.info("Server healthy: \(isHealthy)") - - // Create a new session - let session = try await client.createSession( - workingDirectory: "/tmp", - shell: "/bin/zsh" - ) - logger.info("Created session: \(session.sessionId)") - - // Execute a command - let response = try await client.executeCommand( - sessionId: session.sessionId, - command: "echo 'Hello from VibeTunnel!'" - ) - logger.info("Command output: \(response.output ?? "none")") - - // List all sessions - let sessions = try await client.listSessions() - logger.info("Active sessions: \(sessions.count)") - - // Close the session - try await client.closeSession(id: session.sessionId) - logger.info("Session closed") - } catch { - logger.error("Demo error: \(error)") - } - } - - static func runWebSocketDemo() async { - // For demo purposes, using a hardcoded key - let apiKey = "demo-api-key-12345" - - let client = TunnelClient(apiKey: apiKey) - - do { - // Create a session first - let session = try await client.createSession() - logger.info("Created session for WebSocket: \(session.sessionId)") - - // Connect WebSocket - guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else { - logger.error("Failed to create WebSocket client") - return - } - wsClient.connect() - - // Subscribe to messages - let cancellable = wsClient.messages.sink { message in - switch message.type { - case .output: - logger.info("Output: \(message.data ?? "")") - case .error: - logger.error("Error: \(message.data ?? "")") - default: - logger.info("Message: \(message.type) - \(message.data ?? "")") - } - } - - // Send some commands - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - wsClient.sendCommand("pwd") - - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - wsClient.sendCommand("ls -la") - - try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - - // Disconnect - wsClient.disconnect() - cancellable.cancel() - } catch { - logger.error("WebSocket demo error: \(error)") - } - } -} - -// MARK: - cURL Examples - -// Here are some example cURL commands to test the server: -// -// # Set your API key -// export API_KEY="your-api-key-here" -// -// # Health check (no auth required) -// curl http://localhost:8080/health -// -// # Get server info -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info -// -// # Create a new session -// curl -X POST http://localhost:8080/sessions \ -// -H "X-API-Key: $API_KEY" \ -// -H "Content-Type: application/json" \ -// -d '{ -// "workingDirectory": "/tmp", -// "shell": "/bin/zsh" -// }' -// -// # List all sessions -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions -// -// # Execute a command -// curl -X POST http://localhost:8080/execute \ -// -H "X-API-Key: $API_KEY" \ -// -H "Content-Type: application/json" \ -// -d '{ -// "sessionId": "your-session-id", -// "command": "ls -la" -// }' -// -// # Get session info -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id -// -// # Close a session -// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id -// -// # WebSocket connection (using websocat tool) -// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal diff --git a/VibeTunnel/Resources/tty-fwd b/VibeTunnel/Resources/tty-fwd index e16e5f0c..48d72b48 100755 Binary files a/VibeTunnel/Resources/tty-fwd and b/VibeTunnel/Resources/tty-fwd differ diff --git a/VibeTunnel/SettingsView.swift b/VibeTunnel/SettingsView.swift index 349f9e6b..6e1abf9a 100644 --- a/VibeTunnel/SettingsView.swift +++ b/VibeTunnel/SettingsView.swift @@ -31,6 +31,15 @@ extension Notification.Name { struct SettingsView: View { @State private var selectedTab: SettingsTab = .general + @State private var contentSize: CGSize = .zero + + // Define ideal sizes for each tab + private let tabSizes: [SettingsTab: CGSize] = [ + .general: CGSize(width: 500, height: 300), + .advanced: CGSize(width: 500, height: 400), + .debug: CGSize(width: 600, height: 650), + .about: CGSize(width: 500, height: 550) + ] var body: some View { TabView(selection: $selectedTab) { @@ -58,11 +67,19 @@ struct SettingsView: View { } .tag(SettingsTab.about) } + .frame(width: contentSize.width, height: contentSize.height) + .animatedWindowResizing(size: contentSize) .onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in if let tab = notification.object as? SettingsTab { selectedTab = tab } } + .onChange(of: selectedTab) { _, newTab in + contentSize = tabSizes[newTab] ?? CGSize(width: 500, height: 400) + } + .onAppear { + contentSize = tabSizes[selectedTab] ?? CGSize(width: 500, height: 400) + } } } @@ -280,7 +297,7 @@ struct AdvancedSettingsView: View { } // Create and start new server with the new port - let newServer = TunnelServerDemo(port: port) + let newServer = TunnelServer(port: port) appDelegate.setHTTPServer(newServer) do { @@ -307,7 +324,7 @@ struct AdvancedSettingsView: View { struct DebugSettingsView: View { - @State private var httpServer: TunnelServerDemo? + @State private var httpServer: TunnelServer? @State private var lastError: String? @State private var testResult: String? @State private var isTesting = false @@ -531,7 +548,7 @@ struct DebugSettingsView: View { if shouldStart { // Create a new server if needed if httpServer == nil { - let newServer = TunnelServerDemo(port: serverPort) + let newServer = TunnelServer(port: serverPort) httpServer = newServer // Store reference in AppDelegate if let appDelegate = NSApp.delegate as? AppDelegate { diff --git a/VibeTunnel/Utilities/SettingsWindowDelegate.swift b/VibeTunnel/Utilities/SettingsWindowDelegate.swift new file mode 100644 index 00000000..1bca565a --- /dev/null +++ b/VibeTunnel/Utilities/SettingsWindowDelegate.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI + +/// A window delegate that handles animated resizing of the settings window +class SettingsWindowDelegate: NSObject, NSWindowDelegate { + static let shared = SettingsWindowDelegate() + + private override init() { + super.init() + } + + /// Animates the window to a new size + func animateWindowResize(to newSize: CGSize, duration: TimeInterval = 0.3) { + guard let window = NSApp.windows.first(where: { $0.title.contains("Settings") || $0.title.contains("Preferences") }) else { + return + } + + // Calculate the new frame maintaining the window's top-left position + var newFrame = window.frame + let heightDifference = newSize.height - newFrame.height + newFrame.size = newSize + newFrame.origin.y -= heightDifference // Keep top edge in place + + // Animate the frame change + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(newFrame, display: true) + } + } + + func windowWillClose(_ notification: Notification) { + // Clean up if needed + } +} + +/// A view modifier that sets up the window delegate for animated resizing +struct AnimatedWindowResizing: ViewModifier { + let size: CGSize + + func body(content: Content) -> some View { + content + .onAppear { + setupWindowDelegate() + // Initial resize without animation + SettingsWindowDelegate.shared.animateWindowResize(to: size, duration: 0) + } + .onChange(of: size) { _, newSize in + SettingsWindowDelegate.shared.animateWindowResize(to: newSize) + } + } + + private func setupWindowDelegate() { + Task { @MainActor in + // Small delay to ensure window is created + try? await Task.sleep(for: .milliseconds(100)) + + if let window = NSApp.windows.first(where: { $0.title.contains("Settings") || $0.title.contains("Preferences") }) { + window.delegate = SettingsWindowDelegate.shared + // Disable window resizing by user + window.styleMask.remove(.resizable) + } + } + } +} + +extension View { + func animatedWindowResizing(size: CGSize) -> some View { + modifier(AnimatedWindowResizing(size: size)) + } +} \ No newline at end of file diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index 111fc8f0..67f30372 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -12,7 +12,6 @@ struct VibeTunnelApp: App { Settings { SettingsView() } - .defaultSize(width: 500, height: 450) .commands { CommandGroup(after: .appInfo) { Button("About VibeTunnel") { @@ -37,7 +36,7 @@ struct VibeTunnelApp: App { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private(set) var sparkleUpdaterManager: SparkleUpdaterManager? - private(set) var httpServer: TunnelServerDemo? + private(set) var httpServer: TunnelServer? private let sessionMonitor = SessionMonitor.shared /// Distributed notification name used to ask an existing instance to show the Settings window. @@ -84,7 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Initialize and start HTTP server let serverPort = UserDefaults.standard.integer(forKey: "serverPort") - httpServer = TunnelServerDemo(port: serverPort > 0 ? serverPort : 4020) + httpServer = TunnelServer(port: serverPort > 0 ? serverPort : 4020) Task { do { @@ -117,7 +116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - func setHTTPServer(_ server: TunnelServerDemo?) { + func setHTTPServer(_ server: TunnelServer?) { httpServer = server } diff --git a/VibeTunnel/Views/MenuBarView.swift b/VibeTunnel/Views/MenuBarView.swift index aa1fbdbd..2098616a 100644 --- a/VibeTunnel/Views/MenuBarView.swift +++ b/VibeTunnel/Views/MenuBarView.swift @@ -8,7 +8,7 @@ import SwiftUI struct MenuBarView: View { - @EnvironmentObject var sessionMonitor: SessionMonitor + @Environment(SessionMonitor.self) var sessionMonitor @AppStorage("showInDock") private var showInDock = false var body: some View { @@ -139,7 +139,7 @@ struct SessionRowView: View { struct MenuButtonStyle: ButtonStyle { @State private var isHovered = false - func makeBody(configuration: Configuration) -> some View { + func makeBody(configuration: ButtonStyle.Configuration) -> some View { configuration.label .font(.system(size: 13)) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/docs/modern-swift-refactoring.md b/docs/modern-swift-refactoring.md new file mode 100644 index 00000000..2a8bde8b --- /dev/null +++ b/docs/modern-swift-refactoring.md @@ -0,0 +1,93 @@ +# 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` 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. \ No newline at end of file