Remove the transfer forwarding adapter, borrowed SFTP channel scheduling, legacy assistant send entry points, unreachable Telnet password state, unused Tailscale APIs, and unused host deletion impact projections. Migrate behavioral tests to current paths and cover SFTP passphrase saving. Validation: Swift formatting and shared-copy/localization checks passed. The macOS test run reported failures in ConnectionStatusIconLayoutTests and AgentRuntimeTests; investigation and iOS validation remain pending.
365 lines
14 KiB
Swift
365 lines
14 KiB
Swift
import SwiftUI
|
|
import TermindApplication
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// The same route, checkpoints and progress animation on desktop and mobile.
|
|
public struct ConnectionJourneyRouteView: View {
|
|
let journey: ConnectionJourney
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
public init(journey: ConnectionJourney) { self.journey = journey }
|
|
@ViewBuilder
|
|
public var body: some View {
|
|
if !journey.endpoints.isEmpty {
|
|
GeometryReader { geometry in
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
route(viewportWidth: geometry.size.width)
|
|
.padding(.vertical, 30)
|
|
#if os(macOS)
|
|
.background { JourneyHorizontalScrollerHider() }
|
|
#endif
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.onChange(of: journey.activeEndpointIndex) { _, _ in
|
|
scrollToActivity(proxy)
|
|
}
|
|
.onChange(of: journey.checkpoint) { _, _ in
|
|
scrollToActivity(proxy)
|
|
}
|
|
.onChange(of: journey.localRequirementEndpointIndex) { _, _ in
|
|
scrollToActivity(proxy)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 152)
|
|
}
|
|
}
|
|
|
|
private func route(viewportWidth: CGFloat) -> some View {
|
|
let nodeLaneHeight: CGFloat = 56
|
|
let endpointSpacing: CGFloat = 190
|
|
let labelWidth: CGFloat = 150
|
|
let gapBetweenEndpointFrames = endpointSpacing - labelWidth
|
|
let routeWidth = labelWidth + CGFloat(max(0, journey.endpoints.count - 1)) * endpointSpacing
|
|
let canvasWidth = max(viewportWidth, routeWidth + 58)
|
|
|
|
return ZStack(alignment: .topLeading) {
|
|
HStack(spacing: 0) {
|
|
ForEach(journey.endpoints.indices.dropFirst(), id: \.self) { index in
|
|
JourneySegmentView(
|
|
state: segmentState(for: index),
|
|
checkpoint: journey.checkpoint?.endpointIndex == index
|
|
? journey.checkpoint?.kind : journey.completedCheckpoints[index],
|
|
checkpointIsWaiting: journey.checkpoint?.endpointIndex == index,
|
|
reduceMotion: reduceMotion
|
|
)
|
|
.frame(width: endpointSpacing, height: nodeLaneHeight)
|
|
.id("segment-\(index)")
|
|
}
|
|
}
|
|
.padding(.leading, labelWidth / 2)
|
|
|
|
HStack(alignment: .top, spacing: gapBetweenEndpointFrames) {
|
|
ForEach(Array(journey.endpoints.enumerated()), id: \.element.id) { index, endpoint in
|
|
JourneyEndpointView(
|
|
endpoint: endpoint,
|
|
purpose: journey.purpose,
|
|
state: endpointState(for: index),
|
|
showsActivity: activityEndpointIndex == index,
|
|
localRequirement: journey.localRequirementEndpointIndex == index
|
|
? journey.localRequirement : nil,
|
|
reduceMotion: reduceMotion
|
|
)
|
|
.frame(width: labelWidth, height: 92, alignment: .top)
|
|
.id("endpoint-\(index)")
|
|
}
|
|
}
|
|
}
|
|
.frame(width: routeWidth, height: 92, alignment: .topLeading)
|
|
.frame(width: canvasWidth, height: 92, alignment: .center)
|
|
}
|
|
|
|
private var activityEndpointIndex: Int? {
|
|
guard journey.phase == .connecting, journey.checkpoint == nil,
|
|
let lastIndex = journey.endpoints.indices.last
|
|
else { return nil }
|
|
let candidate: Int
|
|
if journey.activeEndpointIndex > journey.completedEndpointIndex {
|
|
candidate = journey.activeEndpointIndex - 1
|
|
} else {
|
|
candidate = journey.completedEndpointIndex
|
|
}
|
|
return min(max(journey.endpoints.startIndex, candidate), lastIndex)
|
|
}
|
|
|
|
private func endpointState(for index: Int) -> JourneyVisualState {
|
|
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 case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed }
|
|
if targetIndex <= journey.completedEndpointIndex { return .complete }
|
|
if targetIndex == journey.activeEndpointIndex { return .active }
|
|
return .pending
|
|
}
|
|
|
|
private func scrollToActivity(_ proxy: ScrollViewProxy) {
|
|
guard !reduceMotion, let lastIndex = journey.endpoints.indices.last else { return }
|
|
let candidate =
|
|
journey.checkpoint?.endpointIndex
|
|
?? journey.localRequirementEndpointIndex
|
|
?? journey.activeEndpointIndex
|
|
let index = min(max(journey.endpoints.startIndex, candidate), lastIndex)
|
|
let target =
|
|
journey.checkpoint != nil && index > journey.endpoints.startIndex
|
|
? "segment-\(index)" : "endpoint-\(index)"
|
|
withAnimation(.easeInOut(duration: 0.24)) {
|
|
proxy.scrollTo(target, anchor: .center)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
#if os(macOS)
|
|
/// SwiftUI may recreate the native horizontal scroller when its content
|
|
/// changes even when indicators are hidden. Keep the underlying scroll view
|
|
/// scrollable while preventing AppKit from installing a visible scroller.
|
|
private struct JourneyHorizontalScrollerHider: NSViewRepresentable {
|
|
final class HiderView: NSView {
|
|
override func viewDidMoveToSuperview() {
|
|
super.viewDidMoveToSuperview()
|
|
configureScrollView()
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
configureScrollView()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
configureScrollView()
|
|
}
|
|
|
|
func configureScrollView() {
|
|
guard let scrollView = enclosingScrollView else { return }
|
|
scrollView.hasHorizontalScroller = false
|
|
scrollView.horizontalScroller?.isHidden = true
|
|
}
|
|
}
|
|
|
|
func makeNSView(context: Context) -> HiderView { HiderView(frame: .zero) }
|
|
|
|
func updateNSView(_ nsView: HiderView, context: Context) {
|
|
nsView.configureScrollView()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private enum JourneyVisualState {
|
|
case pending
|
|
case active
|
|
case complete
|
|
case failed
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .pending: .secondary.opacity(0.55)
|
|
case .active, .complete: Color.accentColor
|
|
case .failed: Color.red
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct JourneyEndpointView: View {
|
|
let endpoint: ConnectionEndpoint
|
|
let purpose: ConnectionPurpose
|
|
let state: JourneyVisualState
|
|
let showsActivity: Bool
|
|
let localRequirement: ConnectionJourney.LocalRequirement?
|
|
let reduceMotion: Bool
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
ZStack {
|
|
if showsActivity {
|
|
JourneyActivityRing(size: 52, reduceMotion: reduceMotion)
|
|
}
|
|
Circle()
|
|
.fill(.background)
|
|
.frame(width: 38, height: 38)
|
|
Circle()
|
|
.fill(state.color)
|
|
.frame(width: 36, height: 36)
|
|
.overlay {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(Color.white)
|
|
}
|
|
}
|
|
.frame(width: 56, height: 56)
|
|
|
|
Text(endpoint.name)
|
|
.font(.caption.weight(.medium))
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 64)
|
|
}
|
|
.frame(height: 92, alignment: .top)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("\(endpoint.name), \(accessibilityState)")
|
|
}
|
|
|
|
private var symbol: String {
|
|
if let localRequirement {
|
|
switch localRequirement {
|
|
case .identity, .tailscaleUsername: return "person.fill"
|
|
case .passphrase: return "key.fill"
|
|
case .proxyAuthentication: return "lock.shield.fill"
|
|
}
|
|
}
|
|
switch endpoint.role {
|
|
case .local:
|
|
#if os(macOS)
|
|
return "laptopcomputer"
|
|
#else
|
|
return "ipad.and.iphone"
|
|
#endif
|
|
case .jump: return "server.rack"
|
|
case .destination:
|
|
return purpose.symbol(for: .routeDestination)
|
|
}
|
|
}
|
|
|
|
private var accessibilityState: String {
|
|
switch state {
|
|
case .pending: String(localized: "not connected")
|
|
case .active: String(localized: "connecting")
|
|
case .complete: String(localized: "connected")
|
|
case .failed: String(localized: "failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct JourneySegmentView: View {
|
|
let state: JourneyVisualState
|
|
let checkpoint: ConnectionJourney.CheckpointKind?
|
|
let checkpointIsWaiting: Bool
|
|
let reduceMotion: Bool
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Capsule().fill(Color.secondary.opacity(0.32)).frame(height: 4)
|
|
switch state {
|
|
case .complete, .failed:
|
|
Capsule().fill(state.color).frame(height: 4)
|
|
case .active:
|
|
if checkpointIsWaiting {
|
|
HStack(spacing: 0) {
|
|
Rectangle().fill(Color.accentColor)
|
|
.frame(width: geometry.size.width / 2, height: 4)
|
|
Color.clear
|
|
.frame(width: geometry.size.width / 2, height: 4)
|
|
}
|
|
} else {
|
|
JourneyTravelingLine(reduceMotion: reduceMotion)
|
|
}
|
|
case .pending:
|
|
EmptyView()
|
|
}
|
|
|
|
if let checkpoint {
|
|
ZStack {
|
|
if checkpointIsWaiting {
|
|
JourneyActivityRing(size: 42, reduceMotion: reduceMotion)
|
|
}
|
|
Circle().fill(Color.accentColor).frame(width: 28, height: 28)
|
|
Image(systemName: checkpoint == .authentication ? "key.fill" : "checkmark.shield.fill")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(Color.white)
|
|
}
|
|
.transition(.scale(scale: 0.65).combined(with: .opacity))
|
|
.accessibilityLabel(
|
|
checkpoint == .authentication
|
|
? Text("Authentication required") : Text("Host key confirmation required"))
|
|
}
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
.frame(height: 56)
|
|
.animation(reduceMotion ? nil : .easeInOut(duration: 0.24), value: checkpoint)
|
|
}
|
|
}
|
|
|
|
private struct JourneyTravelingLine: View {
|
|
let reduceMotion: Bool
|
|
|
|
var body: some View {
|
|
if reduceMotion {
|
|
HStack(spacing: 0) {
|
|
Rectangle().fill(Color.accentColor).frame(width: 34, height: 4)
|
|
Spacer(minLength: 0)
|
|
}
|
|
} else {
|
|
TimelineView(.animation) { timeline in
|
|
GeometryReader { proxy in
|
|
let time = timeline.date.timeIntervalSinceReferenceDate
|
|
let progress = 0.16 + 0.64 * ((sin(time * 2.4) + 1) / 2)
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.accentColor,
|
|
Color.accentColor.opacity(0.55),
|
|
.clear,
|
|
],
|
|
startPoint: .leading, endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: max(18, proxy.size.width * progress), height: 4)
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct JourneyActivityRing: View {
|
|
let size: CGFloat
|
|
let reduceMotion: Bool
|
|
|
|
var body: some View {
|
|
if reduceMotion {
|
|
Circle().stroke(Color.accentColor.opacity(0.45), lineWidth: 4)
|
|
.frame(width: size, height: size)
|
|
} else {
|
|
TimelineView(.animation) { timeline in
|
|
let rotation =
|
|
timeline.date.timeIntervalSinceReferenceDate
|
|
.truncatingRemainder(dividingBy: 1.25) / 1.25 * 360
|
|
Circle()
|
|
.trim(from: 0.06, to: 0.78)
|
|
.stroke(
|
|
AngularGradient(
|
|
colors: [
|
|
Color.accentColor.opacity(0.15),
|
|
Color.accentColor,
|
|
Color.accentColor.opacity(0.32),
|
|
],
|
|
center: .center
|
|
),
|
|
style: StrokeStyle(lineWidth: 4, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(rotation))
|
|
.frame(width: size, height: size)
|
|
}
|
|
}
|
|
}
|
|
}
|