mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-04 11:05:53 +00:00
iOS: Fix websocket + terminal resize loop (#224)
* Fix websocket + terminal resize loop * Fix * CR
This commit is contained in:
parent
db76cd3c25
commit
4fbb404754
4 changed files with 171 additions and 92 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -124,3 +124,5 @@ test-results-*.json
|
|||
playwright-report/
|
||||
*.png
|
||||
!src/**/*.png
|
||||
.claude/settings.local.json
|
||||
buildServer.json
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
private var connectionErrorTask: Task<Void, Never>?
|
||||
private var resizeDebounceTask: Task<Void, Never>?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue