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

189 lines
7.0 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 keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
var cancelKeyboardAuthentication: (() -> Void)? = 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 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 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("Connection Log").font(.headline)
Spacer()
Button("Copy", 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("Connection Failed", 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("Cancel", action: cancel)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Button("Copy Logs", action: copyLogs)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Spacer()
}
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.frame(maxWidth: .infinity, alignment: .trailing)
}
} else {
HStack {
Button("Cancel", action: cancel)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Button("Copy Logs", action: copyLogs)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Spacer()
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}