Files
Termind/TFI/Features/Ports/MobileForwardingView.swift
T

233 lines
10 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import UIKit
struct MobileForwardingView: View {
let app: MobileAppModel
let reviewScene: MobileSceneModel?
@State private var query: String
@State private var editing: PortForwardingRule?
@State private var showing: UUID?
@State private var removing: PortForwardingRule?
@State private var error: String?
init(app: MobileAppModel, initialQuery: String = "", reviewScene: MobileSceneModel? = nil) {
self.app = app
self.reviewScene = reviewScene
_query = State(initialValue: initialQuery)
}
private var rules: [PortForwardingRule] { app.forwardingLibrary.search(query) }
var body: some View {
MobileCardPage {
if app.forwardingLibrary.rules.isEmpty {
ContentUnavailableView(
"No Port Forwarding", systemImage: "arrow.left.arrow.right",
description: Text("Create a forwarding rule, then start it manually."))
} else if rules.isEmpty {
ContentUnavailableView.search(text: query)
}
MobileCardGrid {
ForEach(rules) { rule in
card(rule)
}
}
}
.navigationTitle("Port Forwarding")
.searchable(text: $query.singleLineText)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Forwarding", systemImage: "plus") { editing = PortForwardingRule(hostID: UUID()) }
}
}
.navigationDestination(item: $showing) { id in
MobileForwardingDetailView(app: app, ruleID: id)
.mobileReviewPage("vault.portForwarding.detail", scene: reviewScene)
}
.sheet(item: $editing) { rule in
MobileForwardingEditor(app: app, rule: rule, isNew: app.forwardingLibrary.portForwarding(rule.id) == nil)
}
.confirmationDialog(
"Delete Forwarding Rules?",
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
guard let removing else { return }
do { try app.forwardingLibrary.delete(removing.id) } catch { self.error = error.localizedDescription }
self.removing = nil
}
}
.alert("Operation Failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
}
private func card(_ rule: PortForwardingRule) -> some View {
let runtime = app.forwardings.state(for: rule.id)
return MobileActionCard {
showing = rule.id
} content: {
MobileForwardingCardContent(
rule: rule, hostName: app.library.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"),
runtime: runtime)
} actions: {
if runtime.isActive {
Button("Stop", systemImage: "stop") { app.forwardings.stop(rule.id) }
} else {
Button(runtime.isFailed ? "Retry" : "Start", systemImage: "play") { start(rule) }.disabled(
!canStart(rule))
}
Button("Copy Address", systemImage: "doc.on.doc") { UIPasteboard.general.string = rule.bindAddress }
Button("Copy SSH Command", systemImage: "apple.terminal") {
UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule)
}
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
Divider()
Button("Edit", systemImage: "pencil") { editing = rule }.disabled(runtime.isActive)
Button("Duplicate", systemImage: "plus.square.on.square") { duplicate(rule) }
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { removing = rule }
}
}
private func canStart(_ rule: PortForwardingRule) -> Bool {
PortForwardingEditorPolicy.canStart(
rule.id, savedRule: rule, savedHost: app.library.host(rule.hostID),
isRuntimeActive: app.forwardings.state(for: rule.id).isActive)
}
private func start(_ rule: PortForwardingRule) {
guard canStart(rule) else { return }
showing = rule.id
app.forwardings.start(rule.id)
}
private func duplicate(_ rule: PortForwardingRule) {
var copy = rule
copy.id = UUID()
if !copy.label.isEmpty { copy.label += String(localized: " Copy") }
do {
editing = try app.forwardingLibrary.save(copy)
} catch { self.error = error.localizedDescription }
}
}
struct MobileForwardingCardContent: View {
let rule: PortForwardingRule
let hostName: String
let runtime: PortForwardingRuntimeState
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
VStack(alignment: .leading, spacing: 4) {
Text(rule.displayName(hostName: hostName)).font(.headline).lineLimit(1)
Text(rule.summary).font(.caption.monospaced()).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
MobileCardChip(text: rule.kind.shortDisplayName)
Text(hostName).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
Spacer(minLength: 0)
MobileCardChip(text: runtime.compactLabel, symbol: runtime.isActive ? "circle.fill" : "circle")
}
}.accessibilityElement(children: .combine)
}
}
struct MobileForwardingDetailView: View {
let app: MobileAppModel
let ruleID: UUID
@Environment(\.dismiss) private var dismiss
@State private var editing: PortForwardingRule?
private var rule: PortForwardingRule? { app.forwardingLibrary.portForwarding(ruleID) }
private var runtime: PortForwardingRuntimeState { app.forwardings.state(for: ruleID) }
var body: some View {
Group {
if let rule {
if let context = app.forwardings.connection(for: ruleID), context.journey.isPresented {
journey(context)
} else {
Form {
Section {
LabeledContent("Status", value: runtime.label)
}
Section("Port Forwarding") {
LabeledContent(
"Host",
value: app.library.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"))
LabeledContent("Direction", value: rule.kind.displayName)
Text(rule.summary).font(.body.monospaced()).textSelection(.enabled)
Button("Copy Address") { UIPasteboard.general.string = rule.bindAddress }
Button("Copy SSH Command") {
UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule)
}
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
}
}
}
} else {
ContentUnavailableView("The forwarding rule is unavailable.", systemImage: "arrow.left.arrow.right")
}
}
.navigationTitle(rule.map { $0.displayName(hostName: app.library.host($0.hostID)?.name) } ?? "")
.toolbar {
ToolbarItem(placement: .primaryAction) {
if runtime.isActive {
Button("Stop", systemImage: "stop") { app.forwardings.stop(ruleID) }
} else if let rule {
Button(
runtime.isFailed ? "Retry" : "Start", systemImage: runtime.isFailed ? "arrow.clockwise" : "play"
) {
app.forwardings.start(ruleID)
}.disabled(
!PortForwardingEditorPolicy.canStart(
ruleID, savedRule: rule, savedHost: app.library.host(rule.hostID),
isRuntimeActive: runtime.isActive))
}
}
ToolbarItem(placement: .secondaryAction) {
if let rule {
Button("Edit", systemImage: "pencil") { editing = rule }.disabled(runtime.isActive)
}
}
}
.sheet(item: $editing) { rule in MobileForwardingEditor(app: app, rule: rule, isNew: false) }
}
private func journey(_ context: PortForwardingConnection) -> some View {
let promptID = context.authenticationPrompt?.id
let hostKey = context.connection.pendingHostKey
let hostKeyRequestID = context.hostKeyRequestID
return MobileConnectionJourneyView(
journey: context.journey, library: app.library, authenticationPrompt: context.authenticationPrompt,
hostKey: hostKey, reconnectState: nil,
submit: { identity, username, password, passphrase, key, save in
guard let promptID else { return }
app.forwardings.submitAuthentication(
for: ruleID, promptID: promptID, identityID: identity,
username: username, password: password, passphrase: passphrase, keyID: key, save: save)
},
accept: { remember in
if let hostKey, let hostKeyRequestID {
app.forwardings.acceptHostKey(
for: ruleID, requestID: hostKeyRequestID, observation: hostKey.observation, remember: remember)
}
},
retry: { app.forwardings.retry(ruleID) }, retryReconnectNow: {}, stopReconnecting: {}, cancel: cancel,
keyboardAuthentication: context.connection.keyboardAuthentication, cancelKeyboardAuthentication: cancel)
}
private func cancel() {
app.forwardings.stop(ruleID)
dismiss()
}
}