diff --git a/.gitignore b/.gitignore index ae80390d..2031e6de 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,5 @@ test-results-*.json playwright-report/ *.png !src/**/*.png +.claude/settings.local.json +buildServer.json diff --git a/ios/VibeTunnel/Services/BufferWebSocketClient.swift b/ios/VibeTunnel/Services/BufferWebSocketClient.swift index 8197854d..32cf03f1 100644 --- a/ios/VibeTunnel/Services/BufferWebSocketClient.swift +++ b/ios/VibeTunnel/Services/BufferWebSocketClient.swift @@ -85,7 +85,14 @@ class BufferWebSocketClient: NSObject { } func connect() { - guard !isConnecting else { return } + guard !isConnecting else { + logger.warning("Already connecting, ignoring connect() call") + return + } + guard !isConnected else { + logger.warning("Already connected, ignoring connect() call") + return + } guard let baseURL else { connectionError = WebSocketError.invalidURL return @@ -98,6 +105,11 @@ class BufferWebSocketClient: NSObject { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) components?.scheme = baseURL.scheme == "https" ? "wss" : "ws" components?.path = "/buffers" + + // Add authentication token as query parameter (not header) + if let token = authenticationService?.getTokenForQuery() { + components?.queryItems = [URLQueryItem(name: "token", value: token)] + } guard let wsURL = components?.url else { connectionError = WebSocketError.invalidURL @@ -620,10 +632,16 @@ class BufferWebSocketClient: NSObject { } func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) { - subscriptions[sessionId] = handler - - Task { - try? await subscribe(to: sessionId) + Task { @MainActor [weak self] in + guard let self else { return } + + // Store the handler + self.subscriptions[sessionId] = handler + + // Send subscription message immediately if connected + if self.isConnected { + try? await self.sendMessage(["type": "subscribe", "sessionId": sessionId]) + } } } @@ -632,10 +650,16 @@ class BufferWebSocketClient: NSObject { } func unsubscribe(from sessionId: String) { - subscriptions.removeValue(forKey: sessionId) - - Task { - try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId]) + Task { @MainActor [weak self] in + guard let self else { return } + + // Remove the handler + self.subscriptions.removeValue(forKey: sessionId) + + // Send unsubscribe message immediately if connected + if self.isConnected { + try? await self.sendMessage(["type": "unsubscribe", "sessionId": sessionId]) + } } } @@ -731,10 +755,13 @@ extension BufferWebSocketClient: WebSocketDelegate { reconnectAttempts = 0 startPingTask() - // Re-subscribe to all sessions - Task { - for sessionId in subscriptions.keys { - try? await subscribe(to: sessionId) + // Re-subscribe to all sessions that have handlers + Task { @MainActor [weak self] in + guard let self else { return } + + let sessionIds = Array(self.subscriptions.keys) + for sessionId in sessionIds { + try? await self.sendMessage(["type": "subscribe", "sessionId": sessionId]) } } } diff --git a/ios/VibeTunnel/Services/LivePreviewManager.swift b/ios/VibeTunnel/Services/LivePreviewManager.swift index 62fca4b9..3dc58766 100644 --- a/ios/VibeTunnel/Services/LivePreviewManager.swift +++ b/ios/VibeTunnel/Services/LivePreviewManager.swift @@ -22,10 +22,6 @@ final class LivePreviewManager { private let updateInterval: TimeInterval = 1.0 private init() { - // Ensure WebSocket is connected when manager is created - if !bufferClient.isConnected { - bufferClient.connect() - } } /// Subscribe to live updates for a session. diff --git a/ios/VibeTunnel/Views/Terminal/TerminalView.swift b/ios/VibeTunnel/Views/Terminal/TerminalView.swift index cff2528b..094adcd8 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalView.swift @@ -587,8 +587,6 @@ struct TerminalView: View { viewModel.sendInput(text) }, onResize: { cols, rows in - viewModel.terminalCols = cols - viewModel.terminalRows = rows viewModel.resize(cols: cols, rows: rows) }, viewModel: viewModel @@ -602,8 +600,6 @@ struct TerminalView: View { viewModel.sendInput(text) }, onResize: { cols, rows in - viewModel.terminalCols = cols - viewModel.terminalRows = rows viewModel.resize(cols: cols, rows: rows) }, viewModel: viewModel @@ -662,14 +658,18 @@ class TerminalViewModel { let session: Session let castRecorder: CastRecorder - var bufferWebSocketClient: BufferWebSocketClient? + let bufferWebSocketClient: BufferWebSocketClient private var connectionStatusTask: Task? private var connectionErrorTask: Task? + private var resizeDebounceTask: Task? + private var hasPerformedInitialResize = false + private var isPerformingInitialResize = false weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator init(session: Session) { self.session = session self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24) + self.bufferWebSocketClient = BufferWebSocketClient.shared setupTerminal() } @@ -689,41 +689,29 @@ class TerminalViewModel { isConnecting = true errorMessage = nil - // Create WebSocket client if needed - if bufferWebSocketClient == nil { - bufferWebSocketClient = BufferWebSocketClient() - } - - // Connect to WebSocket - bufferWebSocketClient?.connect() - - // Load initial snapshot after a brief delay to ensure terminal is ready - Task { @MainActor in - // Wait for terminal view to be initialized - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s - await loadSnapshot() - } - - // Subscribe to terminal events - bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in + // Subscribe to terminal events first (stores the handler) + bufferWebSocketClient.subscribe(to: session.id) { [weak self] event in Task { @MainActor in self?.handleWebSocketEvent(event) } } + // Connect to WebSocket - it will automatically subscribe to stored sessions + bufferWebSocketClient.connect() + // Monitor connection status connectionStatusTask?.cancel() connectionStatusTask = Task { [weak self] in - guard let client = self?.bufferWebSocketClient else { return } + guard let self else { return } while !Task.isCancelled { - let connected = client.isConnected + let connected = self.bufferWebSocketClient.isConnected await MainActor.run { - self?.isConnecting = false - self?.isConnected = connected + self.isConnecting = false + self.isConnected = connected if !connected { - self?.errorMessage = "WebSocket disconnected" + self.errorMessage = "WebSocket disconnected" } else { - self?.errorMessage = nil + self.errorMessage = nil } } try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds @@ -733,12 +721,12 @@ class TerminalViewModel { // Monitor connection errors connectionErrorTask?.cancel() connectionErrorTask = Task { [weak self] in - guard let client = self?.bufferWebSocketClient else { return } + guard let self else { return } while !Task.isCancelled { - if let error = client.connectionError { + if let error = self.bufferWebSocketClient.connectionError { await MainActor.run { - self?.errorMessage = error.localizedDescription - self?.isConnecting = false + self.errorMessage = error.localizedDescription + self.isConnecting = false } } try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds @@ -746,39 +734,13 @@ class TerminalViewModel { } } - @MainActor - private func loadSnapshot() async { - do { - let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id) - - // Process the snapshot events - if let header = snapshot.header { - // Initialize terminal with dimensions from header - terminalCols = header.width - terminalRows = header.height - logger.debug("Snapshot header: \(header.width)x\(header.height)") - } - - // Feed all output events to the terminal - for event in snapshot.events { - if event.type == .output { - // Feed the actual terminal output data - if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator { - coordinator.feedData(event.data) - } - } - } - } catch { - logger.error("Failed to load terminal snapshot: \(error)") - } - } func disconnect() { connectionStatusTask?.cancel() connectionErrorTask?.cancel() - bufferWebSocketClient?.unsubscribe(from: session.id) - bufferWebSocketClient?.disconnect() - bufferWebSocketClient = nil + resizeDebounceTask?.cancel() + bufferWebSocketClient.unsubscribe(from: session.id) + // Note: Don't disconnect the shared client as other views might be using it isConnected = false } @@ -836,12 +798,7 @@ class TerminalViewModel { stopRecording() } - // Load final snapshot for exited session - Task { @MainActor in - // Give the server a moment to finalize the snapshot - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s - await loadSnapshot() - } + // Session has exited - no need to load additional content case .bufferUpdate(let snapshot): // Update terminal buffer directly @@ -894,18 +851,115 @@ class TerminalViewModel { } func resize(cols: Int, rows: Int) { - Task { - do { - try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows) - // If resize succeeded, ensure the flag is cleared + // Guard against invalid dimensions + guard cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 else { + logger.warning("Ignoring invalid resize: \(cols)x\(rows)") + return + } + + // Guard against blocked resize + guard !isResizeBlockedByServer else { + logger.warning("Resize blocked by server, ignoring resize: \(cols)x\(rows)") + return + } + + // Handle initial resize with proper synchronization + if !hasPerformedInitialResize && !isPerformingInitialResize { + isPerformingInitialResize = true + + // Always update UI dimensions immediately for consistency + terminalCols = cols + terminalRows = rows + + // Perform initial resize after a short delay to let layout settle + resizeDebounceTask?.cancel() + resizeDebounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds for initial + guard !Task.isCancelled else { + await MainActor.run { + self?.isPerformingInitialResize = false + } + return + } + await self?.performInitialResize(cols: cols, rows: rows) + } + return + } + + // For subsequent resizes, compare against current UI dimensions (not server dimensions) + guard cols != terminalCols || rows != terminalRows else { + return + } + + // Only allow significant changes for subsequent resizes + let colDiff = abs(cols - terminalCols) + let rowDiff = abs(rows - terminalRows) + + // Only resize if there's a significant change (more than 5 cols/rows difference) + guard colDiff > 5 || rowDiff > 5 else { + logger.debug("Ignoring minor resize change: \(cols)x\(rows) (current: \(terminalCols)x\(terminalRows))") + return + } + + // Update UI dimensions immediately + terminalCols = cols + terminalRows = rows + + resizeDebounceTask?.cancel() + resizeDebounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second for subsequent + guard !Task.isCancelled else { return } + await self?.performResize(cols: cols, rows: rows) + } + } + + private func performInitialResize(cols: Int, rows: Int) async { + logger.info("Performing initial terminal resize: \(cols)x\(rows)") + + do { + try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows) + // If resize succeeded, mark initial resize as complete and clear any server blocks + await MainActor.run { + hasPerformedInitialResize = true + isPerformingInitialResize = false isResizeBlockedByServer = false - } catch { - logger.error("Failed to resize terminal: \(error)") - // Check if the error is specifically about resize being disabled - if case APIError.resizeDisabledByServer = error { + } + } catch { + logger.error("Failed initial terminal resize: \(error)") + // Check if the error is specifically about resize being disabled + if case APIError.resizeDisabledByServer = error { + await MainActor.run { + hasPerformedInitialResize = true // Mark as done even if blocked to prevent retries + isPerformingInitialResize = false + isResizeBlockedByServer = true + } + } else { + // For other errors, allow retry by clearing the in-progress flag but leaving hasPerformedInitialResize false + await MainActor.run { + isPerformingInitialResize = false + } + } + } + } + + private func performResize(cols: Int, rows: Int) async { + logger.info("Resizing terminal: \(cols)x\(rows)") + + do { + try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows) + // If resize succeeded, ensure the flag is cleared + await MainActor.run { + isResizeBlockedByServer = false + } + } catch { + logger.error("Failed to resize terminal: \(error)") + // Check if the error is specifically about resize being disabled + if case APIError.resizeDisabledByServer = error { + await MainActor.run { isResizeBlockedByServer = true } } + // Note: UI dimensions remain as set, representing the actual terminal view size } }