mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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/
|
playwright-report/
|
||||||
*.png
|
*.png
|
||||||
!src/**/*.png
|
!src/**/*.png
|
||||||
|
.claude/settings.local.json
|
||||||
|
buildServer.json
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,14 @@ class BufferWebSocketClient: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect() {
|
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 {
|
guard let baseURL else {
|
||||||
connectionError = WebSocketError.invalidURL
|
connectionError = WebSocketError.invalidURL
|
||||||
return
|
return
|
||||||
|
|
@ -98,6 +105,11 @@ class BufferWebSocketClient: NSObject {
|
||||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||||
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
|
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
|
||||||
components?.path = "/buffers"
|
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 {
|
guard let wsURL = components?.url else {
|
||||||
connectionError = WebSocketError.invalidURL
|
connectionError = WebSocketError.invalidURL
|
||||||
|
|
@ -620,10 +632,16 @@ class BufferWebSocketClient: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
|
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
|
||||||
subscriptions[sessionId] = handler
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
Task {
|
|
||||||
try? await subscribe(to: sessionId)
|
// 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) {
|
func unsubscribe(from sessionId: String) {
|
||||||
subscriptions.removeValue(forKey: sessionId)
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
Task {
|
|
||||||
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId])
|
// 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
|
reconnectAttempts = 0
|
||||||
startPingTask()
|
startPingTask()
|
||||||
|
|
||||||
// Re-subscribe to all sessions
|
// Re-subscribe to all sessions that have handlers
|
||||||
Task {
|
Task { @MainActor [weak self] in
|
||||||
for sessionId in subscriptions.keys {
|
guard let self else { return }
|
||||||
try? await subscribe(to: sessionId)
|
|
||||||
|
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 let updateInterval: TimeInterval = 1.0
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Ensure WebSocket is connected when manager is created
|
|
||||||
if !bufferClient.isConnected {
|
|
||||||
bufferClient.connect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to live updates for a session.
|
/// Subscribe to live updates for a session.
|
||||||
|
|
|
||||||
|
|
@ -587,8 +587,6 @@ struct TerminalView: View {
|
||||||
viewModel.sendInput(text)
|
viewModel.sendInput(text)
|
||||||
},
|
},
|
||||||
onResize: { cols, rows in
|
onResize: { cols, rows in
|
||||||
viewModel.terminalCols = cols
|
|
||||||
viewModel.terminalRows = rows
|
|
||||||
viewModel.resize(cols: cols, rows: rows)
|
viewModel.resize(cols: cols, rows: rows)
|
||||||
},
|
},
|
||||||
viewModel: viewModel
|
viewModel: viewModel
|
||||||
|
|
@ -602,8 +600,6 @@ struct TerminalView: View {
|
||||||
viewModel.sendInput(text)
|
viewModel.sendInput(text)
|
||||||
},
|
},
|
||||||
onResize: { cols, rows in
|
onResize: { cols, rows in
|
||||||
viewModel.terminalCols = cols
|
|
||||||
viewModel.terminalRows = rows
|
|
||||||
viewModel.resize(cols: cols, rows: rows)
|
viewModel.resize(cols: cols, rows: rows)
|
||||||
},
|
},
|
||||||
viewModel: viewModel
|
viewModel: viewModel
|
||||||
|
|
@ -662,14 +658,18 @@ class TerminalViewModel {
|
||||||
|
|
||||||
let session: Session
|
let session: Session
|
||||||
let castRecorder: CastRecorder
|
let castRecorder: CastRecorder
|
||||||
var bufferWebSocketClient: BufferWebSocketClient?
|
let bufferWebSocketClient: BufferWebSocketClient
|
||||||
private var connectionStatusTask: Task<Void, Never>?
|
private var connectionStatusTask: Task<Void, Never>?
|
||||||
private var connectionErrorTask: 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
|
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
|
||||||
|
|
||||||
init(session: Session) {
|
init(session: Session) {
|
||||||
self.session = session
|
self.session = session
|
||||||
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
|
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
|
||||||
|
self.bufferWebSocketClient = BufferWebSocketClient.shared
|
||||||
setupTerminal()
|
setupTerminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -689,41 +689,29 @@ class TerminalViewModel {
|
||||||
isConnecting = true
|
isConnecting = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
// Create WebSocket client if needed
|
// Subscribe to terminal events first (stores the handler)
|
||||||
if bufferWebSocketClient == nil {
|
bufferWebSocketClient.subscribe(to: session.id) { [weak self] event in
|
||||||
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
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.handleWebSocketEvent(event)
|
self?.handleWebSocketEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket - it will automatically subscribe to stored sessions
|
||||||
|
bufferWebSocketClient.connect()
|
||||||
|
|
||||||
// Monitor connection status
|
// Monitor connection status
|
||||||
connectionStatusTask?.cancel()
|
connectionStatusTask?.cancel()
|
||||||
connectionStatusTask = Task { [weak self] in
|
connectionStatusTask = Task { [weak self] in
|
||||||
guard let client = self?.bufferWebSocketClient else { return }
|
guard let self else { return }
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
let connected = client.isConnected
|
let connected = self.bufferWebSocketClient.isConnected
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self?.isConnecting = false
|
self.isConnecting = false
|
||||||
self?.isConnected = connected
|
self.isConnected = connected
|
||||||
if !connected {
|
if !connected {
|
||||||
self?.errorMessage = "WebSocket disconnected"
|
self.errorMessage = "WebSocket disconnected"
|
||||||
} else {
|
} else {
|
||||||
self?.errorMessage = nil
|
self.errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
||||||
|
|
@ -733,12 +721,12 @@ class TerminalViewModel {
|
||||||
// Monitor connection errors
|
// Monitor connection errors
|
||||||
connectionErrorTask?.cancel()
|
connectionErrorTask?.cancel()
|
||||||
connectionErrorTask = Task { [weak self] in
|
connectionErrorTask = Task { [weak self] in
|
||||||
guard let client = self?.bufferWebSocketClient else { return }
|
guard let self else { return }
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if let error = client.connectionError {
|
if let error = self.bufferWebSocketClient.connectionError {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self?.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
self?.isConnecting = false
|
self.isConnecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
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() {
|
func disconnect() {
|
||||||
connectionStatusTask?.cancel()
|
connectionStatusTask?.cancel()
|
||||||
connectionErrorTask?.cancel()
|
connectionErrorTask?.cancel()
|
||||||
bufferWebSocketClient?.unsubscribe(from: session.id)
|
resizeDebounceTask?.cancel()
|
||||||
bufferWebSocketClient?.disconnect()
|
bufferWebSocketClient.unsubscribe(from: session.id)
|
||||||
bufferWebSocketClient = nil
|
// Note: Don't disconnect the shared client as other views might be using it
|
||||||
isConnected = false
|
isConnected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -836,12 +798,7 @@ class TerminalViewModel {
|
||||||
stopRecording()
|
stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load final snapshot for exited session
|
// Session has exited - no need to load additional content
|
||||||
Task { @MainActor in
|
|
||||||
// Give the server a moment to finalize the snapshot
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
|
||||||
await loadSnapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .bufferUpdate(let snapshot):
|
case .bufferUpdate(let snapshot):
|
||||||
// Update terminal buffer directly
|
// Update terminal buffer directly
|
||||||
|
|
@ -894,18 +851,115 @@ class TerminalViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
func resize(cols: Int, rows: Int) {
|
func resize(cols: Int, rows: Int) {
|
||||||
Task {
|
// Guard against invalid dimensions
|
||||||
do {
|
guard cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 else {
|
||||||
try await SessionService().resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
|
logger.warning("Ignoring invalid resize: \(cols)x\(rows)")
|
||||||
// If resize succeeded, ensure the flag is cleared
|
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
|
isResizeBlockedByServer = false
|
||||||
} catch {
|
}
|
||||||
logger.error("Failed to resize terminal: \(error)")
|
} catch {
|
||||||
// Check if the error is specifically about resize being disabled
|
logger.error("Failed initial terminal resize: \(error)")
|
||||||
if case APIError.resizeDisabledByServer = 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
|
isResizeBlockedByServer = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: UI dimensions remain as set, representing the actual terminal view size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue