fix(terminal): unify resumable recovery in connection journey
This commit is contained in:
1 parent
b139fbc841
commit
1398ff94e1
20 files changed
+853
-85
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Actions: View>: View {
|
||||
@ViewBuilder let actions: Actions
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 10) { actions }
|
||||
VStack(spacing: 10) { actions }
|
||||
}
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user