205 lines
7.8 KiB
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)
|
|
}
|
|
}
|