Files
Termind/TFI/UI/Components/MobileConnectionJourneyView.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

197 lines
7.8 KiB
Swift

import SwiftUI
import TermindApplication
import TermindPresentation
import UIKit
struct MobileConnectionJourneyView: View {
let journey: ConnectionJourney
let library: HostLibraryModel
let authenticationPrompt: SSHAuthenticationPrompt?
let hostKey: SSHHostKeyPrompt?
let reconnectState: ReconnectState?
let submit: (UUID?, String, String?, String?, UUID?, Bool) -> Void
let accept: (Bool) -> Void
let retry: () -> Void
let retryReconnectNow: () -> Void
let stopReconnecting: () -> Void
let cancel: () -> Void
var securityKeySigning: SecurityKeySigningCoordinator? = nil
var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
var cancelKeyboardAuthentication: (() -> Void)? = nil
var remoteSession: RemoteSessionCoordinator? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ScrollView {
VStack(spacing: 0) {
ConnectionJourneyHeader(journey: journey) {
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) {
journey.showsLogs.toggle()
}
}
ConnectionJourneyRouteView(journey: journey)
content
.frame(maxWidth: 660)
.padding(.horizontal, 28)
Spacer(minLength: 24)
}
.frame(maxWidth: .infinity, minHeight: 520, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemBackground))
.transition(.opacity)
.accessibilityElement(children: .contain)
}
@ViewBuilder
private var content: some View {
if journey.showsLogs {
logs
} else if let coordinator = securityKeySigning, let request = coordinator.pending {
SecurityKeySigningCard(request: request, coordinator: coordinator).id(request.id)
.connectionFormChrome(isEnabled: true)
} else if let queue = keyboardAuthentication, let challenge = queue.current {
SSHKeyboardInteractiveForm(
challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) },
cancel: {
guard queue.current?.id == challenge.id else { return }
if let cancelKeyboardAuthentication {
cancelKeyboardAuthentication()
} else {
queue.cancel(challenge.id)
}
}, usesContainerChrome: true
)
.id(challenge.id)
} else if let authenticationPrompt {
SSHAuthenticationForm(
prompt: authenticationPrompt, submit: submit, cancel: cancel, library: library
)
.id(authenticationPrompt.id)
} else if let hostKey {
SSHHostKeyConfirmationForm(
prompt: hostKey, displayAddress: hostKey.observation.hostname,
proceed: { accept(false) }, trust: { accept(true) }, deny: cancel)
} else if let remoteSession, remoteSession.prompt != nil {
RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel)
.connectionFormChrome(isEnabled: true)
} else if case .failed(let message) = journey.phase {
MobileJourneyFailureCard(message: message, retry: retry, cancel: cancel, copyLogs: copyLogs)
} else if let reconnectState {
ConnectionJourneyReconnectStatus(
state: reconnectState, retryNow: retryReconnectNow, stop: stopReconnecting)
} else {
ConnectionJourneyStatus(journey: journey)
}
}
private var logs: some View {
VStack(spacing: 0) {
HStack {
Text(AppStrings.Terminal.connectionLog).font(.headline)
Spacer()
Button(AppStrings.Common.copy.swiftUIKey, action: copyLogs)
.controlSize(.small)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.padding(14)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(journey.logs) { entry in
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(entry.date, format: .dateTime.hour().minute().second())
.foregroundStyle(.tertiary)
Text(entry.endpoint)
.foregroundStyle(.secondary)
}
Text(entry.message)
.frame(maxWidth: .infinity, alignment: .leading)
}
.font(.caption.monospaced())
.textSelection(.enabled)
}
}
.padding(14)
}
.frame(maxHeight: 210)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).strokeBorder(.separator.opacity(0.7))
}
}
private func copyLogs() {
UIPasteboard.general.string = journey.plainTextLog
}
}
private struct MobileJourneyFailureCard: View {
let message: String
let retry: () -> Void
let cancel: () -> Void
let copyLogs: () -> Void
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Label(AppStrings.Connections.connectionFailed.swiftUIKey, systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(.red)
Text(message)
.foregroundStyle(.secondary)
.textSelection(.enabled)
actions
}
.padding(18)
.background(Color.red.opacity(0.065), in: RoundedRectangle(cornerRadius: 14))
.overlay {
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Color.red.opacity(0.3))
}
}
@ViewBuilder
private var actions: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack(spacing: 10) {
HStack {
Button(AppStrings.Common.cancel.swiftUIKey, action: cancel)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Button(AppStrings.Terminal.copyLogs.swiftUIKey, action: copyLogs)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Spacer()
}
Button(AppStrings.Common.retry.swiftUIKey, action: retry)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.frame(maxWidth: .infinity, alignment: .trailing)
}
} else {
HStack {
Button(AppStrings.Common.cancel.swiftUIKey, action: cancel)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Button(AppStrings.Terminal.copyLogs.swiftUIKey, action: copyLogs)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Spacer()
Button(AppStrings.Common.retry.swiftUIKey, action: retry)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}