Files
Termind/TFI/UI/Components/MobileConnectionJourneyView.swift

205 lines
7.8 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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
var allowsPersistingHostKey = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.mobileConnectionFeedback) private var feedbackContext
@State private var successFeedback = MobileConnectionSuccessFeedback()
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)
.onAppear { observeSuccess() }
.onChange(of: ObjectIdentifier(journey)) { _, _ in observeSuccess() }
.onDisappear { successFeedback.stop() }
}
private func observeSuccess() {
successFeedback.observe(
journey, canPresent: feedbackContext.isForeground, feedback: feedbackContext.succeeded)
}
@ViewBuilder
private var content: some View {
switch ConnectionJourneyContent(
journey: journey, securityKeySigning: securityKeySigning, keyboardAuthentication: keyboardAuthentication,
authenticationPrompt: authenticationPrompt, hostKey: hostKey, remoteSession: remoteSession,
reconnectState: reconnectState
) {
case .logs:
logs
case .securityKey(let coordinator, let request):
SecurityKeySigningCard(request: request, coordinator: coordinator).id(request.id)
.connectionFormChrome(isEnabled: true)
case .keyboardAuthentication(let queue, let challenge):
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)
case .authentication(let authenticationPrompt):
SSHAuthenticationForm(
prompt: authenticationPrompt, submit: submit, cancel: cancel, library: library
)
.id(authenticationPrompt.id)
case .hostKey(let hostKey):
SSHHostKeyConfirmationForm(
prompt: hostKey, displayAddress: hostKey.observation.hostname,
proceed: { accept(false) }, trust: allowsPersistingHostKey ? { accept(true) } : nil, deny: cancel)
case .remoteSession(let remoteSession):
RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel)
.connectionFormChrome(isEnabled: true)
case .transportRecovery:
ConnectionJourneyTransportRecoveryStatus(
journey: journey, retryNow: retryReconnectNow, close: cancel, copyLogs: copyLogs)
case .failure(let message):
MobileJourneyFailureCard(message: message, retry: retry, cancel: cancel, copyLogs: copyLogs)
case .reconnect(let reconnectState):
ConnectionJourneyReconnectStatus(
state: reconnectState, retryNow: retryReconnectNow, stop: stopReconnecting)
case .status:
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 {
ConnectionJourneyFailureCard(message: message) { actions }
}
@ViewBuilder
private var actions: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack(spacing: 10) {
HStack {
secondaryActions
Spacer()
}
retryButton.frame(maxWidth: .infinity, alignment: .trailing)
}
} else {
HStack {
secondaryActions
Spacer()
retryButton
}
}
}
@ViewBuilder
private var secondaryActions: some View {
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)
}
private var retryButton: some View {
Button(AppStrings.Common.retry.swiftUIKey, action: retry)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}