diff --git a/FrameworkTests/ResumableTerminalEngineTests.swift b/FrameworkTests/ResumableTerminalEngineTests.swift index c46b1da6..4bfcd235 100644 --- a/FrameworkTests/ResumableTerminalEngineTests.swift +++ b/FrameworkTests/ResumableTerminalEngineTests.swift @@ -7,6 +7,24 @@ import Testing @Suite struct ResumableTerminalEngineTests { + @Test func recoveryRetryUsesTheExistingEngineWithoutReplayingStartup() async throws { + let events = EngineEvents() + let engine = TestEngine(events: events) + let client = client(engine, events: events) + try await connect(client) + await events.emit(.recovering) + try await client.retryNow() + #expect(Array(events.values.suffix(2)) == ["prepareRetry", "retry"]) + #expect(events.values.filter { $0 == "prepare" }.count == 1) + #expect(events.values.filter { $0.hasPrefix("start:") }.count == 1) + #expect(!events.values.contains(where: { $0.hasPrefix("write:") })) + await client.close() + let closedEvents = events.values + await events.emit(.recovering) + await events.emit(.connected) + #expect(events.values == closedEvents) + } + @Test func missingRegistrationFailsBeforeWakeOrBootstrapAndClearsFacadeSecrets() async throws { let events = EngineEvents() let channel = RemoteConnectionCoordinator(transportEngines: .init(factories: [:])).makePTYChannel( diff --git a/FrameworkTests/TransportRecoveryJourneyTests.swift b/FrameworkTests/TransportRecoveryJourneyTests.swift new file mode 100644 index 00000000..daa2ac1b --- /dev/null +++ b/FrameworkTests/TransportRecoveryJourneyTests.swift @@ -0,0 +1,91 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct TransportRecoveryJourneyTests { + @Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession]) + func recoveryRetainsRouteAndLogsWithoutReplayingAuthentication(_ purpose: ConnectionPurpose) { + let (journey, host) = fixture(purpose) + journey.waitForAuthentication(at: host.id) + journey.resume() + journey.succeed() + journey.hide() + let endpoints = journey.endpoints + let logs = journey.logs + journey.showsLogs = true + + journey.updateTransportRecovery(.recovering, message: "Recovering") + let recoveryLogs = journey.logs + journey.updateTransportRecovery(.recovering, message: "Recovering") + + #expect(journey.isPresented) + #expect(journey.mode == .reconnection) + #expect(journey.purpose == purpose) + #expect(journey.endpoints == endpoints) + #expect(journey.logs == recoveryLogs) + #expect(Array(journey.logs.prefix(logs.count)) == logs) + #expect(journey.logs.last?.message == "Recovering") + #expect(journey.showsLogs) + #expect(journey.checkpoint == nil) + #expect(journey.completedCheckpoints.isEmpty) + #expect(journey.activeEndpointIndex == endpoints.count - 1) + } + + @Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession]) + func suspensionRecoveryAndFailureHaveDistinctPresentation(_ purpose: ConnectionPurpose) { + let (journey, _) = fixture(purpose) + journey.updateTransportRecovery(.suspended, message: "Paused") + #expect(journey.transportRecovery == .suspended) + journey.updateTransportRecovery(.recovering, message: "Recovering") + #expect(journey.transportRecovery == .recovering) + journey.updateTransportRecovery(.failed, message: "Original transport error") + #expect(journey.phase == .failed("Original transport error")) + #expect(journey.transportRecovery == .failed) + let logs = journey.logs + journey.updateTransportRecovery(.failed, message: "Original transport error") + #expect(journey.logs == logs) + journey.hide() + #expect(journey.transportRecovery == nil) + #expect(!journey.isPresented) + } + + @Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession]) + func renewedRecoveryCancelsThePreviousSuccessTimer(_ purpose: ConnectionPurpose) async throws { + let (journey, _) = fixture(purpose) + journey.updateTransportRecovery(.recovering, message: "Recovering") + journey.succeed() + #expect(journey.phase == .succeeded) + #expect(journey.transportRecovery == nil) + journey.updateTransportRecovery(.recovering, message: "Lost again") + try await Task.sleep(for: .milliseconds(750)) + #expect(journey.phase == .connecting) + #expect(journey.transportRecovery == .recovering) + journey.succeed() + try await Task.sleep(for: .milliseconds(750)) + #expect(!journey.isPresented) + } + + @Test func ordinaryConnectionFailuresAndSSHReconnectKeepTheirExistingPresentation() { + let (journey, host) = fixture(.moshSession) + journey.updateTransportRecovery(.failed, message: "Unavailable") + journey.begin(host: host, route: [host], purpose: .moshSession, username: "user") + journey.fail("Bootstrap failed") + #expect(journey.transportRecovery == nil) + #expect(journey.mode == .connection) + journey.begin(host: host, route: [host], purpose: .sshSession, username: "user", mode: .reconnection) + journey.updateTransportRecovery(.recovering, message: "Must not apply") + #expect(journey.transportRecovery == nil) + #expect(!journey.plainTextLog.contains("Must not apply")) + } + + private func fixture(_ purpose: ConnectionPurpose) -> (ConnectionJourney, Host) { + let host = Host(name: "Server", host: "example.invalid") + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: purpose, username: "user") + return (journey, host) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 4f224828..c5a2a286 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -57781,6 +57781,53 @@ } } }, + "Restoring the existing terminal session" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestehende Terminalsitzung wird wiederhergestellt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restoring the existing terminal session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restauration de la session de terminal existante" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既存のターミナルセッションを復元中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 터미널 세션 복원 중" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在恢复原终端会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在復原原終端階段" + } + } + } + }, "Result" : { "extractionState" : "manual", "localizations" : { @@ -75173,6 +75220,53 @@ } } }, + "The terminal session is paused. It will resume when the app returns to the foreground." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Terminalsitzung ist pausiert. Sie wird fortgesetzt, wenn die App wieder im Vordergrund ist." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The terminal session is paused. It will resume when the app returns to the foreground." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session de terminal est en pause. Elle reprendra lorsque l’app reviendra au premier plan." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナルセッションは一時停止中です。アプリがフォアグラウンドに戻ると再開します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 세션이 일시 정지되었습니다. 앱이 포그라운드로 돌아오면 재개됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端会话已暂停。应用回到前台时将恢复。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端階段已暫停。應用程式回到前景時將會復原。" + } + } + } + }, "The terminal session is suspended." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index a7b74736..73aab389 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -64,6 +64,12 @@ public final class ConnectionJourney { case failed(String) } + public enum TransportRecovery: Equatable { + case recovering + case suspended + case failed + } + public enum CheckpointKind: Equatable { case authentication case hostKey @@ -92,6 +98,7 @@ public final class ConnectionJourney { public private(set) var mode: Mode = .connection public private(set) var endpoints: [ConnectionEndpoint] = [] public private(set) var phase: Phase = .hidden + public private(set) var transportRecovery: TransportRecovery? public private(set) var activeEndpointIndex = 0 public private(set) var completedEndpointIndex = 0 public private(set) var checkpoint: Checkpoint? @@ -122,6 +129,7 @@ public final class ConnectionJourney { username: String?, mode: Mode = .connection ) { completionTask?.cancel() + transportRecovery = nil let nextEndpoints = [ ConnectionEndpoint( @@ -169,6 +177,7 @@ public final class ConnectionJourney { public func beginSerial(name: String, address: String, mode: Mode = .connection) { completionTask?.cancel() + transportRecovery = nil purpose = .serialSession self.mode = mode endpoints = [ @@ -202,6 +211,7 @@ public final class ConnectionJourney { host: Host, username: String?, mode: Mode = .connection ) { completionTask?.cancel() + transportRecovery = nil purpose = .telnetSession self.mode = mode let address = host.displayAddress(for: .telnet) @@ -391,8 +401,30 @@ public final class ConnectionJourney { checkpoint = nil } + /// Recovery retains the original route and log, but does not replay SSH authentication. + public func updateTransportRecovery(_ state: TransportRecovery, message: String) { + guard purpose == .moshSession || purpose == .eternalTerminalSession else { return } + guard transportRecovery != state || (state == .failed && phase != .failed(message)) else { return } + completionTask?.cancel() + transportRecovery = state + mode = .reconnection + checkpoint = nil + completedCheckpoints = [:] + localRequirement = nil + localRequirementEndpointIndex = nil + activeEndpointIndex = max(0, endpoints.count - 1) + if state == .failed { + fail(message) + transportRecovery = state + } else { + phase = .connecting + log(endpoint: destination?.name ?? purpose.logName, message: message) + } + } + public func succeed() { guard !endpoints.isEmpty else { return } + transportRecovery = nil checkpoint = nil localRequirement = nil localRequirementEndpointIndex = nil @@ -410,6 +442,7 @@ public final class ConnectionJourney { public func fail(_ message: String, at hostID: Host.ID? = nil) { completionTask?.cancel() + transportRecovery = nil checkpoint = nil localRequirement = nil localRequirementEndpointIndex = nil @@ -427,6 +460,7 @@ public final class ConnectionJourney { public func hide() { completionTask?.cancel() + transportRecovery = nil checkpoint = nil completedCheckpoints = [:] localRequirement = nil diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift index 6711a6c9..0b9e9045 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -86,6 +86,9 @@ public struct ConnectionJourneyRouteView: View { guard journey.phase == .connecting, journey.checkpoint == nil, let lastIndex = journey.endpoints.indices.last else { return nil } + if let recovery = journey.transportRecovery { + return recovery == .recovering ? lastIndex : nil + } let candidate: Int if journey.activeEndpointIndex > journey.completedEndpointIndex { candidate = journey.activeEndpointIndex - 1 @@ -96,12 +99,22 @@ public struct ConnectionJourneyRouteView: View { } private func endpointState(for index: Int) -> JourneyVisualState { + if let recovery = journey.transportRecovery { + if index == journey.activeEndpointIndex { + return recovery == .failed ? .failed : .active + } + return index == 0 ? .complete : .pending + } if case .failed = journey.phase, index <= journey.activeEndpointIndex { return .failed } if index <= journey.completedEndpointIndex { return .complete } return .pending } private func segmentState(for targetIndex: Int) -> JourneyVisualState { + if let recovery = journey.transportRecovery { + guard targetIndex == journey.activeEndpointIndex else { return .pending } + return recovery == .failed ? .failed : recovery == .recovering ? .active : .pending + } if case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed } if targetIndex <= journey.completedEndpointIndex { return .complete } if targetIndex == journey.activeEndpointIndex { return .active } diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 6e24c150..92e9e3f4 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -199,17 +199,10 @@ public struct ConnectionJourneyReconnectStatus: View { .lineLimit(2) .multilineTextAlignment(.center) .textSelection(.enabled) - ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { - retryButton - stopButton - } - VStack(spacing: 10) { - retryButton - stopButton - } + ConnectionJourneyActions { + retryButton + stopButton } - .controlSize(.large) } .padding(.top, 4) } @@ -235,3 +228,79 @@ public struct ConnectionJourneyReconnectStatus: View { return String(localized: AppStrings.Connections.reconnectAttempt(state.attempt, retryingIn: seconds)) } } + +public struct ConnectionJourneyTransportRecoveryStatus: View { + let journey: ConnectionJourney + let retryNow: () -> Void + let close: () -> Void + let copyLogs: () -> Void + + public init( + journey: ConnectionJourney, retryNow: @escaping () -> Void, + close: @escaping () -> Void, copyLogs: @escaping () -> Void + ) { + self.journey = journey + self.retryNow = retryNow + self.close = close + self.copyLogs = copyLogs + } + + public var body: some View { + VStack(spacing: 12) { + if case .failed(let message) = journey.phase { + Label(AppStrings.Connections.connectionFailed.swiftUIKey, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.red) + Text(message) + if message != String(localized: AppStrings.Transports.sessionUnavailable) { + Text(AppStrings.Transports.sessionUnavailable) + .foregroundStyle(.secondary) + } + } else { + Label( + journey.transportRecovery == .suspended + ? AppStrings.Transports.sessionSuspended : AppStrings.Transports.recoveringSession, + systemImage: journey.transportRecovery == .suspended + ? "pause.circle" : "arrow.trianglehead.2.clockwise.rotate.90" + ) + .font(.headline) + } + ConnectionJourneyActions { actions } + } + .multilineTextAlignment(.center) + .textSelection(.enabled) + .padding(.top, 8) + } + + @ViewBuilder + private var actions: some View { + if journey.transportRecovery == .recovering { + Button(AppStrings.Transports.retryNow.swiftUIKey, action: retryNow) + .buttonStyle(.borderedProminent) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + if journey.transportRecovery == .failed { + Button(AppStrings.Terminal.copyLogs.swiftUIKey, action: copyLogs) + .buttonStyle(.bordered) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + Button(AppStrings.Transports.closeSession.swiftUIKey, role: .destructive, action: close) + .buttonStyle(.bordered) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } +} + +private struct ConnectionJourneyActions: View { + @ViewBuilder let actions: Actions + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { actions } + VStack(spacing: 10) { actions } + } + .controlSize(.large) + } +} diff --git a/Frameworks/TermindPresentation/Strings/Transports.swift b/Frameworks/TermindPresentation/Strings/Transports.swift index b479f496..df784a82 100644 --- a/Frameworks/TermindPresentation/Strings/Transports.swift +++ b/Frameworks/TermindPresentation/Strings/Transports.swift @@ -11,6 +11,10 @@ extension AppStrings { public static var portRange: LocalizedStringResource { "Port Range" } public static var retryNow: LocalizedStringResource { "Retry Now" } public static var closeSession: LocalizedStringResource { "Close Session" } + public static var recoveringSession: LocalizedStringResource { "Restoring the existing terminal session" } + public static var sessionSuspended: LocalizedStringResource { + "The terminal session is paused. It will resume when the app returns to the foreground." + } public static var sessionUnavailable: LocalizedStringResource { "The terminal session cannot continue. Open a new session to connect again." } diff --git a/Frameworks/TermindPresentation/TerminalTransportStatusView.swift b/Frameworks/TermindPresentation/TerminalTransportStatusView.swift deleted file mode 100644 index f4d3a851..00000000 --- a/Frameworks/TermindPresentation/TerminalTransportStatusView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI -import TermindApplication - -public struct TerminalTransportStatusView: View { - private let state: ResumableTerminalState? - private let retry: () -> Void - private let close: () -> Void - - public init(state: ResumableTerminalState?, retry: @escaping () -> Void, close: @escaping () -> Void) { - self.state = state - self.retry = retry - self.close = close - } - - public var body: some View { - if state == .recovering || state == .suspended || state == .failed || state == .ended { - VStack(spacing: 12) { - Text( - state == .failed || state == .ended - ? AppStrings.Transports.sessionUnavailable : AppStrings.Connections.reconnecting) - HStack { - if state == .recovering { - Button(AppStrings.Transports.retryNow.swiftUIKey, action: retry) - } - Button(AppStrings.Transports.closeSession.swiftUIKey, role: .destructive, action: close) - } - } - .padding() - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) - .padding() - } - } -} diff --git a/TFI/Features/Terminal/MobileTerminalInitialFocus.swift b/TFI/Features/Terminal/MobileTerminalInitialFocus.swift index 891add6e..f2562e54 100644 --- a/TFI/Features/Terminal/MobileTerminalInitialFocus.swift +++ b/TFI/Features/Terminal/MobileTerminalInitialFocus.swift @@ -5,14 +5,21 @@ import SwiftUI struct MobileTerminalInitialFocus: ViewModifier { let terminal: TerminalViewState let isReady: Bool + var blocksInput = false func body(content: Content) -> some View { content .onAppear { requestFocusIfReady() } .onChange(of: isReady) { _, _ in requestFocusIfReady() } + .onChange(of: blocksInput) { _, _ in requestFocusIfReady() } } private func requestFocusIfReady() { + terminal.attachedPlatformView?.isUserInteractionEnabled = !blocksInput + if blocksInput { + terminal.attachedPlatformView?.resignFirstResponder() + return + } guard isReady else { return } terminal.requestFocus() } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 74e7ebe8..63f8195a 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -73,6 +73,12 @@ final class MobileWorkspace: Identifiable { private(set) var busy = false var reconnectState: ReconnectState? { connection.reconnectState } var journey: ConnectionJourney { connection.journey } + var acceptsTerminalInput: Bool { + !transport.isResumable + || (!journey.isPresented + && transportState != .recovering && transportState != .suspended + && transportState != .failed && transportState != .ended && transportState != .closed) + } var operationError: String? private var connectionFailure: String? private(set) var authenticationPrompt: SSHAuthenticationPrompt? @@ -342,7 +348,7 @@ final class MobileWorkspace: Identifiable { backend = InMemoryTerminalSession( write: { data in Task { @MainActor in - await relay.owner?.receiveInteractiveTerminalInput(data) + await relay.owner?.receiveTerminalWrite(data) } }, resize: { size in Task { @MainActor in await relay.owner?.resize(size) } }, @@ -554,15 +560,24 @@ final class MobileWorkspace: Identifiable { } func receiveInteractiveTerminalInput(_ data: Data) async { + guard acceptsTerminalInput else { return } + await receiveTerminalWrite(data) + } + + private func receiveTerminalWrite(_ data: Data) async { + // Native interaction is disabled under Journey; this callback also carries + // protocol replies, which must still reach the existing transport. invalidateCommandGeneration() if descriptor.kind == .telnet { telnet?.write(data) - } else { - await writeSSHDirectly(data) + } else if let ssh { + terminalContext.noteInput() + await connectionServices.writeSSH(ssh, data) } } private func writeSSHDirectly(_ data: Data) async { + guard acceptsTerminalInput else { return } terminalContext.noteInput() guard let ssh else { return } await connectionServices.writeSSH(ssh, data) @@ -849,6 +864,7 @@ final class MobileWorkspace: Identifiable { connectionFailure = TerminalTransportError.invalidCheckpoint.localizedDescription status = connectionFailure ?? "" busy = false + presentTransportRecovery(.failed, message: connectionFailure ?? "") } } } @@ -1204,6 +1220,7 @@ final class MobileWorkspace: Identifiable { journey.receive(.connected(host.id)) } guard generation == token, !Task.isCancelled, transportState != .ended else { return } + if transportState == .recovering || transportState == .suspended || transportState == .failed { return } let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config) transferSnapshot = additionalSnapshot if let sftp { @@ -1223,7 +1240,7 @@ final class MobileWorkspace: Identifiable { } } status = String(localized: AppStrings.Connections.connected) - journey.succeed() + if journey.phase != .succeeded { journey.succeed() } if sessionPersistence.attachment != nil { pendingCommand = nil pendingConnectionStartupCommand = nil @@ -1266,7 +1283,8 @@ final class MobileWorkspace: Identifiable { authenticationPrompt = nil } var requiresUserInteraction = false - var preventsAutomaticReconnect = error is CancellationError || managedSFTPFailure + var preventsAutomaticReconnect = + error is CancellationError || managedSFTPFailure || transport.isResumable if let sshError = error as? TermindSSHError, !managedSFTPFailure { switch sshError { case .missingCredential, .missingUsername, .keyPassphraseRequired, .authenticationFailed: @@ -1317,7 +1335,10 @@ final class MobileWorkspace: Identifiable { } } if !requiresUserInteraction { - if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect { + if requiresTransportRecovery, transport.isResumable { + connection.stopRecovery() + presentTransportRecovery(.failed, message: connectionFailure ?? error.localizedDescription) + } else if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect { scheduleReconnect(after: error.localizedDescription) } else { connection.stopRecovery() @@ -1447,8 +1468,17 @@ final class MobileWorkspace: Identifiable { resumeAfterBackground() return } - if transport.isResumable, let ssh { - Task { try? await ssh.retryTransport() } + if transport.isResumable { + guard transportState == .recovering, let ssh else { return } + let token = generation + Task { [weak self] in + do { + try await ssh.retryTransport() + } catch { + guard let self, self.generation == token else { return } + self.receiveTransportState(.failed, failure: error.localizedDescription) + } + } return } connection.reconnect.retryNow() @@ -1546,7 +1576,8 @@ final class MobileWorkspace: Identifiable { try await ssh.resumeTransport() self.transportSuspension = nil } catch { - self.receiveTransportState(.failed) + guard self.generation == token else { return } + self.receiveTransportState(.failed, failure: error.localizedDescription) } } return @@ -1572,18 +1603,28 @@ final class MobileWorkspace: Identifiable { } } - func receiveTransportState(_ state: ResumableTerminalState) { - guard transportState != .ended else { return } + func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) { + guard transportState != .ended, transportState != .closed else { return } + guard state != transportState || failure != nil else { return } transportState = state switch state { case .connected: connected = true busy = false status = String(localized: AppStrings.Connections.connected) - journey.hide() + if journey.transportRecovery != nil { + _ = connection.markConnected() + connectionFailure = nil + journey.succeed() + } case .recovering, .suspended: connected = false status = String(localized: AppStrings.Connections.reconnecting) + presentTransportRecovery( + state == .recovering ? .recovering : .suspended, + message: String( + localized: state == .recovering + ? AppStrings.Transports.recoveringSession : AppStrings.Transports.sessionSuspended)) case .ended: connected = false busy = false @@ -1595,17 +1636,39 @@ final class MobileWorkspace: Identifiable { Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) } handleRemoteExit(.success(0)) case .failed: + let initialConnection = + !connection.hasConnectedOnce && resumeCheckpoint == nil && !requiresTransportRecovery + && journey.mode == .connection && journey.isPresented && journey.transportRecovery == nil connected = false busy = false connection.resetRecovery() - connectionFailure = TerminalTransportError.sessionFailed.localizedDescription + connectionFailure = failure ?? TerminalTransportError.sessionFailed.localizedDescription status = connectionFailure ?? "" + if initialConnection { + journey.fail(connectionFailure ?? "") + } else { + presentTransportRecovery(.failed, message: connectionFailure ?? "") + } let recoveryID = recoveryID Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) } + // Transport cleanup can follow a failure; the workspace owns dismissing its Journey. case .connecting, .closed: break } } + private func presentTransportRecovery(_ state: ConnectionJourney.TransportRecovery, message: String) { + if journey.endpoints.isEmpty { + let host = + connectionHost(descriptor.hostID) + ?? Host(id: descriptor.hostID, name: descriptor.title, host: descriptor.title) + journey.begin( + host: host, route: connectionRoute?.hosts ?? hosts.route(for: host), purpose: connectionPurpose, + username: resumeUsername ?? connectionRoute?.usernames[host.id] ?? usernameHint, + mode: .reconnection) + } + journey.updateTransportRecovery(state, message: message) + } + func restoreRemoteSession(_ reference: RemoteSessionReference?) { sessionPersistence.attachment = reference activitySession.recovery?.remoteSession = reference.flatMap { try? JSONEncoder().encode($0) } @@ -1698,8 +1761,7 @@ final class MobileWorkspace: Identifiable { private func handleConnectionFailure(_ message: String) { if transport.isResumable { - connectionFailure = message - receiveTransportState(.failed) + receiveTransportState(.failed, failure: message) return } guard connection.hasConnectedOnce else { return } @@ -1791,6 +1853,7 @@ final class MobileWorkspace: Identifiable { func disconnect() { if transport.isResumable { + transportState = .closed let recoveryID = recoveryID Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) } recoveryLease = nil diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 0a5320b4..6181304e 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -116,11 +116,6 @@ struct MobileWorkspaceView: View { MobileRegionReader { regions in MobilePaneLayout(frames: [regions.continuous]) { workspaceContent - .overlay(alignment: .top) { - TerminalTransportStatusView( - state: workspace.transportState, retry: workspace.retryReconnectNow, - close: { closeWorkspace(workspace.id) }) - } .overlay(alignment: .bottom) { VStack(spacing: 12) { if showsCommandGeneratorOverlay { @@ -451,7 +446,8 @@ struct MobileWorkspaceView: View { MobileTerminalInitialFocus( terminal: workspace.terminal, isReady: workspaceIsVisible && scenePhase == .active && workspace.connected - && !showsJourney) + && !showsJourney, + blocksInput: showsJourney) ) .id(workspace.id) .onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in diff --git a/TFI/UI/Components/MobileConnectionJourneyView.swift b/TFI/UI/Components/MobileConnectionJourneyView.swift index 5b19f741..824f7b82 100644 --- a/TFI/UI/Components/MobileConnectionJourneyView.swift +++ b/TFI/UI/Components/MobileConnectionJourneyView.swift @@ -80,6 +80,9 @@ struct MobileConnectionJourneyView: View { } else if let remoteSession, remoteSession.prompt != nil { RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel) .connectionFormChrome(isEnabled: true) + } else if journey.transportRecovery != nil { + ConnectionJourneyTransportRecoveryStatus( + journey: journey, retryNow: retryReconnectNow, close: cancel, copyLogs: copyLogs) } else if case .failed(let message) = journey.phase { MobileJourneyFailureCard(message: message, retry: retry, cancel: cancel, copyLogs: copyLogs) } else if let reconnectState { diff --git a/TFITests/MobileTerminalBackgroundTests.swift b/TFITests/MobileTerminalBackgroundTests.swift index 73dc781c..14dc8dec 100644 --- a/TFITests/MobileTerminalBackgroundTests.swift +++ b/TFITests/MobileTerminalBackgroundTests.swift @@ -11,6 +11,53 @@ import UIKit @Suite(.serialized, .requiresKeyWindow) @MainActor struct MobileTerminalBackgroundTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal], [true, false]) + func transportJourneyDisablesNativeInputWithoutReplacingTheSurface( + _ transport: HostTerminalProtocol, foreground: Bool + ) async throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Recovery", host: "test.invalid") + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)), + hosts: app.library, transientHost: host) + workspace.receiveTransportState(.connected) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + let controller = UIHostingController( + rootView: MobileWorkspaceView( + library: app.library, commandGenerator: app.commandGenerator, + workspace: workspace, workspaces: [workspace.descriptor], selectWorkspace: { _ in }, + renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false, + openSameDirectory: { _ in }, closeWorkspace: { _ in }, closeOtherWorkspaces: { _ in } + ) + .environment(\.scenePhase, foreground ? .active : .inactive)) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + workspace.disconnect() + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await settle(controller) + let native = try #require(workspace.terminal.attachedPlatformView) + let surface = try #require(workspace.terminal.surface) + #expect(native.becomeFirstResponder()) + workspace.receiveTransportState(.recovering) + try await settle(controller) + #expect(!native.isFirstResponder) + #expect(!native.isUserInteractionEnabled) + #expect(workspace.terminal.surface === surface) + workspace.receiveTransportState(.connected) + try await Task.sleep(for: .milliseconds(750)) + try await settle(controller) + #expect(native.isUserInteractionEnabled) + #expect(native.isFirstResponder == foreground) + #expect(workspace.terminal.attachedPlatformView === native) + #expect(workspace.terminal.surface === surface) + } + @Test func oscBackgroundFillsSafeAreaWithoutChangingTerminalGeometryOrIdentity() async throws { let app = try MobileAppModel(inMemory: true) let workspace = MobileWorkspace( diff --git a/TFITests/MobileTerminalRestorationTests.swift b/TFITests/MobileTerminalRestorationTests.swift index a798133e..ee7106e5 100644 --- a/TFITests/MobileTerminalRestorationTests.swift +++ b/TFITests/MobileTerminalRestorationTests.swift @@ -8,6 +8,100 @@ import Testing @Suite @MainActor struct MobileTerminalRestorationTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func initialFailureRemainsRetryableAfterTransportCleanup(_ transport: HostTerminalProtocol) { + let host = Host(name: "Server", host: "example.invalid") + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)), + hosts: HostLibraryModel(hosts: [host])) + defer { workspace.disconnect() } + workspace.journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "user") + workspace.receiveTransportState(.connected) + #expect(workspace.journey.phase == .connecting) + workspace.receiveTransportState(.failed, failure: "Bootstrap failed") + workspace.receiveTransportState(.closed) + #expect(workspace.journey.phase == .failed("Bootstrap failed")) + #expect(workspace.journey.transportRecovery == nil) + #expect(workspace.journey.mode == .connection) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func unavailableCheckpointShowsJourneyWithoutOpeningANewShell(_ transport: HostTerminalProtocol) async throws { + for incompatibleCheckpoint in [false, true] { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var host = Host(name: "Server", host: "example.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .terminal(transport)) + if incompatibleCheckpoint { + let other: HostTerminalProtocol = transport == .mosh ? .eternalTerminal : .mosh + let record = TerminalRecoveryRecord( + id: descriptor.id, host: host, username: "user", transport: other) + let lease = await fixture.recovery.register(record.id) + try await fixture.recovery.save( + record, checkpoint: .init(transport: other, payload: Data("opaque".utf8)), lease: lease) + } + var connections = 0 + var services = MobileWorkspaceConnectionServices.live + services.connectSSH = { _, _, _, _, _, _ in connections += 1 } + let workspace = MobileWorkspace( + descriptor: descriptor, hosts: HostLibraryModel(hosts: [host]), connectionServices: services) + defer { workspace.disconnect() } + workspace.terminalRecoveryStore = fixture.recovery + workspace.requiresTransportRecovery = true + workspace.connect() + #expect(await eventually { workspace.journey.transportRecovery == .failed }) + #expect(workspace.journey.purpose == .terminal(transport)) + #expect(workspace.journey.phase == .failed(TerminalTransportError.invalidCheckpoint.localizedDescription)) + #expect(workspace.requiresTransportRecovery) + workspace.retryReconnectNow() + #expect(connections == 0) + } + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func recoveryUsesJourneyAndRetainsTerminalWithoutSchedulingSSHReconnect(_ transport: HostTerminalProtocol) + async throws + { + let host = Host(name: "Server", host: "example.invalid") + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)), + hosts: HostLibraryModel(hosts: [host])) + defer { workspace.disconnect() } + let terminal = workspace.terminal + workspace.journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "user") + workspace.receiveTransportState(.connected) + #expect(workspace.journey.phase == .connecting) + workspace.journey.hide() + workspace.receiveTransportState(.suspended) + #expect(workspace.journey.transportRecovery == .suspended) + #expect(!workspace.acceptsTerminalInput) + workspace.receiveTransportState(.recovering) + #expect(workspace.journey.transportRecovery == .recovering) + #expect(workspace.reconnectState == nil) + workspace.receiveTransportState(.connected) + #expect(workspace.journey.phase == .succeeded) + #expect(!workspace.acceptsTerminalInput) + let logs = workspace.journey.logs + workspace.receiveTransportState(.connected) + #expect(workspace.journey.logs == logs) + try await Task.sleep(for: .milliseconds(750)) + #expect(!workspace.journey.isPresented) + #expect(workspace.acceptsTerminalInput) + #expect(workspace.terminal === terminal) + workspace.receiveTransportState(.failed, failure: "Original resume error") + #expect(workspace.journey.phase == .failed("Original resume error")) + #expect(workspace.journey.transportRecovery == .failed) + workspace.receiveTransportState(.closed) + #expect(workspace.journey.phase == .failed("Original resume error")) + workspace.disconnect() + workspace.receiveTransportState(.recovering) + workspace.receiveTransportState(.connected) + #expect(!workspace.journey.isPresented) + #expect(!workspace.connected) + } + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) func remoteEndClosesWorkspaceOnceButRecoveryAndFailureDoNot(_ transport: HostTerminalProtocol) { let host = Host(name: "Server", host: "example.invalid") @@ -140,6 +234,9 @@ struct MobileTerminalRestorationTests { scene.open(descriptor.destinationURL) let workspace = try #require(scene.activeWorkspace) #expect(await eventually { workspace.transportState == .failed }) + #expect(workspace.journey.transportRecovery == .failed) + #expect(workspace.journey.isPresented) + #expect(workspace.journey.purpose == .moshSession) scene.open(descriptor.destinationURL) workspace.connect() #expect(await eventually { workspace.transportState == .failed }) diff --git a/TFITests/Screenshots/MobileConnectionJourneyRenderingTests.swift b/TFITests/Screenshots/MobileConnectionJourneyRenderingTests.swift index ebc231ac..48fb9f67 100644 --- a/TFITests/Screenshots/MobileConnectionJourneyRenderingTests.swift +++ b/TFITests/Screenshots/MobileConnectionJourneyRenderingTests.swift @@ -13,6 +13,33 @@ import UIKit .serialized) @MainActor struct MobileConnectionJourneyRenderingTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func rendersTransportRecoveryAndFailure(_ transport: HostTerminalProtocol) async throws { + let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid") + let library = HostLibraryModel(hosts: [host]) + for language in ["en", "zh-Hans", "zh-Hant"] { + for state in [ + ConnectionJourney.TransportRecovery.recovering, .suspended, .failed, + ] { + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "deploy") + journey.updateTransportRecovery( + state, message: String(localized: AppStrings.Transports.sessionUnavailable)) + let view = MobileConnectionJourneyView( + journey: journey, library: library, authenticationPrompt: nil, hostKey: nil, + reconnectState: nil, submit: { _, _, _, _, _, _ in }, accept: { _ in }, + retry: { + Issue.record("Transport recovery must not offer a new connection") + }, retryReconnectNow: {}, stopReconnecting: {}, cancel: {}) + try await capture( + "\(transport.rawValue)-\(state)", language: language, scheme: .light, view: view) + try await capture( + "\(transport.rawValue)-\(state)-large-type", language: language, scheme: .light, + view: view.environment(\.dynamicTypeSize, .accessibility3)) + } + } + } + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func rendersInteractiveAuthentication(_ language: String) async throws { let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid") diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 1ec19ee9..50e5ab6d 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -195,6 +195,13 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { var reconnectState: ReconnectState? { connection.reconnectState } + var acceptsTerminalInput: Bool { + !transport.isResumable + || (!connectionJourney.isPresented + && transportState != .recovering && transportState != .suspended + && transportState != .failed && transportState != .ended && transportState != .closed) + } + func openPTYSFTPChannel(for attemptID: UUID?) async throws -> any SFTPChannelProtocol { guard status == .connected, connectionAttemptID == attemptID, let ssh else { throw SFTPError.notConnected @@ -322,6 +329,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } func requestTerminalFocus() { + guard !connectionJourney.isPresented, sessionPersistence.prompt == nil else { return } terminalSurface.requestFocus() } @@ -846,12 +854,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { connectionTask = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil + if transport.isResumable { transportState = .closed } status = .disconnected } func retryReconnectNow() { - if transport.isResumable, let ssh { - Task { try? await ssh.retryTransport() } + if transport.isResumable { + guard transportState == .recovering, let ssh else { return } + let attemptID = connectionAttemptID + Task { [weak self] in + do { + try await ssh.retryTransport() + } catch { + guard let self, self.connectionAttemptID == attemptID else { return } + self.receiveTransportState(.failed, failure: error.localizedDescription) + } + } return } reconnectController.retryNow() @@ -996,6 +1014,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { try Task.checkCancellation() guard connectionAttemptID == attemptID else { throw CancellationError() } guard transportState != .ended else { return } + if transportState == .recovering || transportState == .suspended || transportState == .failed { return } transferSnapshot = statusStore?.additionalConnectionSnapshot(from: configuration) ?? SSHAdditionalConnectionSnapshot(configuration: configuration) @@ -1017,9 +1036,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { pendingConnectionStartupCommand = nil homeResolution = .unavailable } - if presentsConnectionJourney { + if presentsConnectionJourney, connectionJourney.phase != .succeeded { connectionJourney.succeed() - } else { + } else if !presentsConnectionJourney { connectionJourney.hide() } if wasReconnecting, transport == .ssh, sessionPersistence.attachment == nil { @@ -1038,26 +1057,56 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { } } - func receiveTransportState(_ state: ResumableTerminalState) { - guard transportState != .ended else { return } + func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) { + guard transportState != .ended, transportState != .closed else { return } + guard state != transportState || failure != nil else { return } transportState = state switch state { case .connected: status = .connected - connectionJourney.hide() - case .recovering: + if connectionJourney.transportRecovery != nil { + _ = connection.markConnected() + connectionJourney.succeed() + } + case .recovering, .suspended: status = .reconnecting + presentTransportRecovery( + state == .recovering ? .recovering : .suspended, + message: String( + localized: state == .recovering + ? AppStrings.Transports.recoveringSession : AppStrings.Transports.sessionSuspended)) case .ended: // These protocols report session end, not the remote process's numeric exit status. handleRemoteExit(.success(0)) case .failed: + let initialConnection = + !connection.hasConnectedOnce && connectionJourney.mode == .connection + && connectionJourney.isPresented && connectionJourney.transportRecovery == nil connection.resetRecovery() - status = .failed(TerminalTransportError.sessionFailed.localizedDescription) - case .suspended: status = .reconnecting + let message = failure ?? TerminalTransportError.sessionFailed.localizedDescription + status = .failed(message) + if initialConnection { + connectionJourney.fail(message) + } else { + presentTransportRecovery(.failed, message: message) + } + // Transport cleanup can follow a failure; the pane owns dismissing its Journey. case .connecting, .closed: break } } + private func presentTransportRecovery(_ state: ConnectionJourney.TransportRecovery, message: String) { + if connectionJourney.endpoints.isEmpty { + let host = + connectionRoute?.host(hostID) ?? statusStore?.host(hostID) ?? transientHost + ?? Host(id: hostID, name: title, host: title) + connectionJourney.begin( + host: host, route: connectionRoute?.hosts ?? [host], purpose: .terminal(transport), + username: connectionRoute?.usernames[hostID] ?? usernameHint, mode: .reconnection) + } + connectionJourney.updateTransportRecovery(state, message: message) + } + /// A normal remote-shell exit owns the pane lifecycle, not the reusable /// Ghostty surface lifecycle. Marking the in-memory backend as finished /// makes the surface permanently stop producing input, which is incorrect @@ -1138,7 +1187,11 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { status = .disconnected inputRouter?.closeSSHSessionPane(afterExit: self) case .failure(let error): - beginAutomaticReconnect(after: error.localizedDescription) + if transport.isResumable { + receiveTransportState(.failed, failure: error.localizedDescription) + } else { + beginAutomaticReconnect(after: error.localizedDescription) + } } } @@ -1295,17 +1348,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// captured user interaction. Mouse/focus reports and protocol replies use /// the direct path even while Broadcast Input is enabled. func handleTerminalWrite(_ data: Data, isInteractiveInput: Bool) { + guard !isInteractiveInput || acceptsTerminalInput else { return } if isInteractiveInput { invalidateCommandGeneration() } if isInteractiveInput, let inputRouter { inputRouter.routeInteractiveInput(data, from: self) } else { - writeInputDirectly(data) + writeInputDirectly(data, isInteractiveInput: isInteractiveInput) } } /// The terminal side of the routing boundary. Broadcast targets call this /// rather than feeding bytes back through the router, preventing recursion. - func writeInputDirectly(_ data: Data) { + func writeInputDirectly(_ data: Data, isInteractiveInput: Bool = true) { + guard !isInteractiveInput || acceptsTerminalInput else { return } terminalContext.noteInput() if let inputSink { inputSink(data) diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 6be66a13..1de92b29 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -31,6 +31,9 @@ struct TerminalPlaceholderView: View { @State private var sftpBindingID: UUID? private var uploads: TerminalUploadModel { sshSession.terminalUpload } + private var showsJourney: Bool { + sshSession.connectionJourney.isPresented || sshSession.sessionPersistence.prompt != nil + } /// The surface decides the pane's size; everything else is an overlay. /// @@ -45,13 +48,12 @@ struct TerminalPlaceholderView: View { /// terminal alone answers for the pane. var body: some View { terminalSurface + .opacity(showsJourney ? 0 : 1) + .allowsHitTesting(!showsJourney) + .accessibilityHidden(showsJourney) .desktopTerminalChrome(state: sshSession.terminalState) .overlay(alignment: .bottom) { uploadDecoration } .overlay { connectionJourney } - .overlay(alignment: .top) { - TerminalTransportStatusView( - state: sshSession.transportState, retry: sshSession.retryReconnectNow, close: closePane) - } .animation(Motion.panel, value: uploads.notice) .onAppear { // The mounted view is the final source of truth after any drag, @@ -59,7 +61,7 @@ struct TerminalPlaceholderView: View { // here so a stale layout callback cannot leave live keystrokes on a // sshSession whose router was cleared while it moved. sshSession.inputRouter = workspace - terminalFocused = requestsFocus + terminalFocused = requestsFocus && !showsJourney TerminalFontCatalog.shared.refresh() uploadBindingID = sshSession.terminalViewOwner.bindUpload( action: chooseUploadFiles, @@ -81,9 +83,12 @@ struct TerminalPlaceholderView: View { for: uploadBindingID, isAvailable: { isVisible && uploads.uploadTargetProblem == nil }) } .onChange(of: requestsFocus) { _, focused in - terminalFocused = focused + terminalFocused = focused && !showsJourney if focused { sshSession.requestTerminalFocus() } } + .onChange(of: showsJourney) { _, presented in + terminalFocused = requestsFocus && isVisible && !presented + } .onChange(of: externalFileDrop?.id) { guard let externalFileDrop else { return } uploads.receiveFiles(externalFileDrop.urls, connections: app.connections, transfers: app.sftpTransfers) @@ -196,8 +201,8 @@ struct TerminalPlaceholderView: View { PersistentTerminalSurfaceView( state: sshSession.terminalState, owner: sshSession.terminalViewOwner, - isVisible: isVisible, - requestsFocus: requestsFocus + isVisible: isVisible && !showsJourney, + requestsFocus: requestsFocus && !showsJourney ) #else TerminalSurfaceView(context: sshSession.terminalState) diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index b8925e44..e875c131 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -247,6 +247,9 @@ struct ConnectionJourneyView: View { } else if let remoteSession, remoteSession.prompt != nil { RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel) .desktopConnectionFormChrome(isEnabled: true) + } else if journey.transportRecovery != nil { + ConnectionJourneyTransportRecoveryStatus( + journey: journey, retryNow: retryReconnectNow ?? retry, close: cancel, copyLogs: copyLogs) } else if case .failed(let message) = journey.phase { JourneyFailureCard( message: message, retry: retry, cancel: cancel, diff --git a/TFMTests/TerminalTransportJourneyRenderingTests.swift b/TFMTests/TerminalTransportJourneyRenderingTests.swift new file mode 100644 index 00000000..adb8fbf9 --- /dev/null +++ b/TFMTests/TerminalTransportJourneyRenderingTests.swift @@ -0,0 +1,94 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@testable import Termind + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct TerminalTransportJourneyRenderingTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func recoveryHidesAndDefocusesTheMountedTerminal(_ transport: HostTerminalProtocol) async throws { + let host = TermindDomain.Host(name: "Recovery", host: "test.invalid") + let pane = Pane.sshSession(host: host, transport: transport) + let session = try #require(pane.sshSession) + let workspace = Workspace(pane: pane) + let navigation = WindowModel(workspaces: [workspace]) + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + session.receiveTransportState(.connected) + let controller = NSHostingController( + rootView: TerminalPlaceholderView( + sshSession: session, workspace: workspace, isVisible: true, requestsFocus: true, + externalFileDrop: nil, externalRemoteDrop: nil, closePane: {} + ) + .environment(app).environment(navigation)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.makeKeyAndOrderFront(nil) + defer { + session.disconnect() + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(250)) + let terminalController = session.terminalState.controller + #expect(session.terminalState.isSurfaceVisible) + #expect(session.terminalViewOwner.hasKeyboardFocusForTesting) + session.receiveTransportState(.recovering) + try await Task.sleep(for: .milliseconds(250)) + #expect(!session.terminalState.isSurfaceVisible) + #expect(!session.terminalViewOwner.hasKeyboardFocusForTesting) + session.receiveTransportState(.connected) + try await Task.sleep(for: .milliseconds(900)) + #expect(session.terminalState.isSurfaceVisible) + #expect(session.terminalState.controller === terminalController) + #expect(session.terminalViewOwner.hasKeyboardFocusForTesting) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func rendersRecoveryAndFailureInShortPanes(_ transport: HostTerminalProtocol) async throws { + let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid") + for language in ["en", "zh-Hans", "zh-Hant"] { + for state in [ConnectionJourney.TransportRecovery.recovering, .suspended, .failed] { + let journey = ConnectionJourney() + journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "deploy") + journey.updateTransportRecovery( + state, message: String(localized: AppStrings.Transports.sessionUnavailable)) + let view = ConnectionJourneyView( + journey: journey, submitAuthentication: { _, _, _, _, _, _ in }, + continueHostKey: { _ in }, trustHostKey: { _ in }, denyHostKey: {}, + retry: { Issue.record("Recovery must not offer a new connection") }, cancel: {}, + retryReconnectNow: {} + ) + .environment(\.locale, Locale(identifier: language)) + .frame(width: 480, height: 440) + let controller = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.setContentSize(NSSize(width: 480, height: 440)) + window.makeKeyAndOrderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(100)) + controller.view.layoutSubtreeIfNeeded() + #expect(controller.view.bounds.height == 440) + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + Attachment.record(data, named: "\(transport.rawValue)-\(state)-\(language).png") + } + } + } +} diff --git a/TFMTests/TerminalTransportJourneyTests.swift b/TFMTests/TerminalTransportJourneyTests.swift new file mode 100644 index 00000000..0e6a7464 --- /dev/null +++ b/TFMTests/TerminalTransportJourneyTests.swift @@ -0,0 +1,81 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import Termind + +@Suite @MainActor +struct TerminalTransportJourneyTests { + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func initialConnectedEventDoesNotDismissBootstrapJourney(_ transport: HostTerminalProtocol) { + let host = Host(name: "Server", host: "example.invalid") + let session = SSHSessionModel(host: host) + session.transport = transport + defer { session.disconnect() } + session.connectionJourney.begin(host: host, route: [host], purpose: .terminal(transport), username: "user") + session.receiveTransportState(.connected) + #expect(session.connectionJourney.phase == .connecting) + #expect(session.connectionJourney.mode == .connection) + session.receiveTransportState(.failed, failure: "Bootstrap failed") + #expect(session.connectionJourney.phase == .failed("Bootstrap failed")) + #expect(session.connectionJourney.transportRecovery == nil) + #expect(session.connectionJourney.mode == .connection) + session.receiveTransportState(.closed) + #expect(session.connectionJourney.phase == .failed("Bootstrap failed")) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func recoveryPreservesTerminalAndBlocksInteractiveAndBroadcastInput(_ transport: HostTerminalProtocol) async throws + { + let host = Host(name: "Server", host: "example.invalid") + let session = SSHSessionModel(host: host) + session.transport = transport + defer { session.disconnect() } + let surface = session.terminalState + var writes: [Data] = [] + session.inputSink = { writes.append($0) } + + session.receiveTransportState(.recovering) + #expect(session.connectionJourney.purpose == .terminal(transport)) + #expect(session.connectionJourney.transportRecovery == .recovering) + #expect(session.reconnectState == nil) + session.handleTerminalWrite(Data("interactive".utf8), isInteractiveInput: true) + session.writeInputDirectly(Data("broadcast".utf8)) + #expect(writes.isEmpty) + session.handleTerminalWrite(Data("protocol reply".utf8), isInteractiveInput: false) + #expect(writes == [Data("protocol reply".utf8)]) + + session.receiveTransportState(.suspended) + #expect(session.connectionJourney.transportRecovery == .suspended) + session.receiveTransportState(.recovering) + session.receiveTransportState(.connected) + #expect(session.connectionJourney.phase == .succeeded) + #expect(!session.acceptsTerminalInput) + let logs = session.connectionJourney.logs + session.receiveTransportState(.connected) + #expect(session.connectionJourney.logs == logs) + try await Task.sleep(for: .milliseconds(750)) + #expect(session.acceptsTerminalInput) + #expect(session.terminalState === surface) + session.writeInputDirectly(Data("after".utf8)) + #expect(writes.last == Data("after".utf8)) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) + func failureKeepsOriginalErrorAndClosedSessionIgnoresLateEvents(_ transport: HostTerminalProtocol) { + let session = SSHSessionModel(host: Host(name: "Server", host: "example.invalid")) + session.transport = transport + session.receiveTransportState(.failed, failure: "Transport refused resume") + #expect(session.connectionJourney.phase == .failed("Transport refused resume")) + #expect(session.connectionJourney.transportRecovery == .failed) + #expect(session.reconnectState == nil) + session.receiveTransportState(.closed) + #expect(session.connectionJourney.phase == .failed("Transport refused resume")) + session.disconnect() + session.receiveTransportState(.recovering) + session.receiveTransportState(.connected) + #expect(!session.connectionJourney.isPresented) + #expect(session.status == .disconnected) + } +}