379 lines
17 KiB
Swift
379 lines
17 KiB
Swift
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
@Observable
|
|
final class PortForwardingPageState {
|
|
var query = ""
|
|
var deleting: Set<UUID> = []
|
|
var error: String?
|
|
}
|
|
|
|
struct PortForwardingPageView: ManagedCardPage {
|
|
typealias SectionID = String
|
|
typealias Item = PortForwardingRule
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(VaultWorkEditingSession.self) private var editingSession
|
|
@Bindable var state: PortForwardingPageState
|
|
|
|
var managedSections: [ManagedCardSection<String, PortForwardingRule>] {
|
|
[
|
|
ManagedCardSection(
|
|
id: "forwardings", title: String(localized: "Port Forwarding"),
|
|
items: app.forwardings.search(state.query), canAcceptDrop: false, showsWhenEmpty: false)
|
|
]
|
|
}
|
|
var managedSelection: ManagedCardSelection<UUID> {
|
|
ManagedCardSelection(
|
|
selected: { Set(window.vaultSelection(for: .portForwarding).compactMap(\.forwardingID)) },
|
|
update: { ids in
|
|
window.setVaultSelection(
|
|
app.forwardings.rules.filter { ids.contains($0.id) }.map { .forwarding($0.id) },
|
|
for: .portForwarding)
|
|
})
|
|
}
|
|
var managedCapabilities: ManagedCardCapabilities<String, UUID> {
|
|
ManagedCardCapabilities(selection: .multiple, marqueeSelection: true)
|
|
}
|
|
var isManagedSourceEmpty: Bool { app.forwardings.rules.isEmpty }
|
|
|
|
func managedHeader(for section: ManagedCardSection<String, Item>, isDropTarget: Bool) -> some View {
|
|
CardSectionHeader(title: section.title, count: section.items.count)
|
|
}
|
|
func managedCard(for item: Item, context: ManagedCardItemContext<Item>) -> some View {
|
|
card(
|
|
item, selected: context.isSelected,
|
|
select: { select(item, action: context.select) }, extend: context.extendSelection
|
|
)
|
|
.contextMenu {
|
|
if context.actionItems.count > 1 {
|
|
Button("Stop", systemImage: MenuSymbol.stop) {
|
|
app.forwarding.stop(
|
|
context.actionItems.filter { app.forwarding.state(for: $0.id).isActive }.map(\.id))
|
|
}
|
|
.disabled(!context.actionItems.contains { app.forwarding.state(for: $0.id).isActive })
|
|
} else {
|
|
if app.forwarding.state(for: item.id).isActive {
|
|
Button("Stop", systemImage: MenuSymbol.stop) { app.forwarding.stop(item.id) }
|
|
} else {
|
|
Button(
|
|
app.forwarding.state(for: item.id).isFailed ? "Retry" : "Start",
|
|
systemImage: MenuSymbol.start
|
|
) { start(item) }.disabled(!canStart(item))
|
|
}
|
|
Menu("Copy", systemImage: MenuSymbol.copy) {
|
|
Button("Address", systemImage: MenuSymbol.copy) {
|
|
copyToPasteboard(item.bindAddress)
|
|
}
|
|
Button("SSH Command", systemImage: MenuSymbol.ssh) {
|
|
if let command = app.forwardings.sshCommand(for: item) {
|
|
copyToPasteboard(command)
|
|
}
|
|
}
|
|
.disabled(app.forwardings.sshCommand(for: item) == nil)
|
|
}
|
|
Divider()
|
|
Button("Edit", systemImage: MenuSymbol.edit) { open(item) }
|
|
Button("Duplicate", systemImage: MenuSymbol.duplicate) { duplicate(item) }
|
|
}
|
|
Divider()
|
|
DestructiveMenuButton("Delete") {
|
|
state.deleting = Set(context.actionItems.map(\.id))
|
|
}
|
|
}
|
|
}
|
|
func managedGhost(for item: Item) -> some View { card(item, selected: false, select: {}, extend: {}) }
|
|
func managedBlankAreaMenu(for section: String?) -> [NativeMenuEntry] {
|
|
window.creationMenu(for: .portForwarding, locale: .current)
|
|
}
|
|
func managedBlankAreaPrimaryAction() {
|
|
guard let route = window.vaultWorkRoute, case .forwardingEditor = route else { return }
|
|
editingSession.requestTransition(route: route, action: window.closeVaultWork)
|
|
}
|
|
func managedEmptyState() -> some View {
|
|
ContentUnavailableView {
|
|
Label("No Port Forwarding", systemImage: "arrow.left.arrow.right")
|
|
} description: {
|
|
Text("Create a forwarding rule, then start it manually.")
|
|
} actions: {
|
|
Button("New Forwarding") { window.openVaultWork(.forwardingEditor(nil), on: .portForwarding) }
|
|
}
|
|
}
|
|
func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) }
|
|
var managedPresentation: ManagedCardDeletionPresentation<UUID> {
|
|
ManagedCardDeletionPresentation(
|
|
deleting: $state.deleting, error: $state.error, ids: app.forwardings.rules.map(\.id),
|
|
confirmationTitle: deletionTitle, errorTitle: "Operation Failed",
|
|
explanation: deletionExplanation,
|
|
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
|
|
delete: {
|
|
do { try app.forwardings.delete(state.deleting) } catch { state.error = error.localizedDescription }
|
|
state.deleting = []
|
|
})
|
|
}
|
|
|
|
private var deletionTitle: LocalizedStringKey {
|
|
state.deleting.count == 1
|
|
? "Delete Forwarding Rule?"
|
|
: "Delete \(state.deleting.count) Forwarding Rules?"
|
|
}
|
|
|
|
private var deletionExplanation: Text {
|
|
state.deleting.count == 1
|
|
? Text("Any active forwarding session for this rule will be stopped.")
|
|
: Text("Any active forwarding sessions for the selected rules will be stopped.")
|
|
}
|
|
|
|
private func card(_ rule: Item, selected: Bool, select: @escaping () -> Void, extend: @escaping () -> Void)
|
|
-> some View
|
|
{
|
|
let runtime = app.forwarding.state(for: rule.id)
|
|
let activate = {
|
|
if app.forwarding.state(for: rule.id).isActive { app.forwarding.stop(rule.id) } else { start(rule) }
|
|
}
|
|
return CardTile(
|
|
selectionID: rule.id, isSelected: selected,
|
|
select: select, extendSelection: extend, activate: activate, activatesOnReturn: true
|
|
) {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
HStack {
|
|
CardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? TermindColor.accent)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(rule.displayName(hostName: app.hostLibrary.host(rule.hostID)?.name))
|
|
.font(.headline).lineLimit(1)
|
|
Text(rule.summary).font(.caption).foregroundStyle(.secondary)
|
|
.lineLimit(1).truncationMode(.middle)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
CardChip(text: rule.kind.shortDisplayName)
|
|
Text(app.hostLibrary.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"))
|
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
CardChip(
|
|
text: runtime.compactLabel, symbol: runtime.isActive ? "circle.fill" : "circle",
|
|
tint: runtime.isActive ? .green : nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private func canStart(_ rule: Item) -> Bool {
|
|
PortForwardingEditorPolicy.canStart(
|
|
rule.id, savedRule: rule, savedHost: app.hostLibrary.host(rule.hostID),
|
|
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
|
|
}
|
|
private func select(_ rule: Item, action: @escaping () -> Void) {
|
|
guard let route = window.vaultWorkRoute, case .forwardingEditor(let editingID) = route else {
|
|
action()
|
|
return
|
|
}
|
|
guard editingID != rule.id else {
|
|
action()
|
|
return
|
|
}
|
|
editingSession.requestTransition(route: route) {
|
|
action()
|
|
open(rule)
|
|
}
|
|
}
|
|
private func open(_ rule: Item) { window.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding) }
|
|
private func start(_ rule: Item) {
|
|
guard canStart(rule) else { return }
|
|
open(rule)
|
|
app.forwarding.start(rule.id)
|
|
}
|
|
private func duplicate(_ rule: Item) {
|
|
var copy = rule
|
|
copy.id = UUID()
|
|
if !copy.label.isEmpty { copy.label += String(localized: " Copy") }
|
|
do {
|
|
open(try app.forwardings.save(copy))
|
|
} catch { state.error = error.localizedDescription }
|
|
}
|
|
}
|
|
|
|
struct PortForwardingDetailView: View {
|
|
let ruleID: UUID?
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(VaultWorkEditingSession.self) private var editingSession
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var draft: PortForwardingRule
|
|
@State private var original: PortForwardingRule
|
|
@State private var loaded = false
|
|
@State private var error: String?
|
|
@State private var deletionError: String?
|
|
@State private var confirmsDeletion = false
|
|
@State private var editingToken: UUID?
|
|
|
|
init(ruleID: UUID?) {
|
|
self.ruleID = ruleID
|
|
let initial = PortForwardingRule(hostID: UUID())
|
|
_draft = State(initialValue: initial)
|
|
_original = State(initialValue: initial)
|
|
}
|
|
|
|
private var saved: PortForwardingRule? { ruleID.flatMap(app.forwardings.portForwarding) }
|
|
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: draft.id) }
|
|
private var normalized: PortForwardingRule? { PortForwardingEditorPolicy.normalized(draft) }
|
|
private var canSave: Bool {
|
|
!runtime.isActive && normalized != nil && app.hostLibrary.host(draft.hostID) != nil
|
|
&& (ruleID == nil || saved != nil)
|
|
}
|
|
private var isDirty: Bool { draft != original }
|
|
|
|
var body: some View {
|
|
VaultDetailScaffold(
|
|
title: ruleID == nil ? "New Forwarding" : "Edit Forwarding",
|
|
symbol: "arrow.left.arrow.right", close: cancelEditing, closeLabel: "Cancel"
|
|
) {
|
|
let context = app.forwarding.connection(for: draft.id)
|
|
ZStack {
|
|
if let context, context.journey.isPresented {
|
|
journey(context)
|
|
} else {
|
|
Form {
|
|
if ruleID != nil {
|
|
Section {
|
|
LabeledContent("Status", value: runtime.label)
|
|
if runtime.isActive {
|
|
Button("Stop") { app.forwarding.stop(draft.id) }
|
|
} else {
|
|
Button(runtime.isFailed ? "Retry" : "Start") { app.forwarding.start(draft.id) }
|
|
.disabled(
|
|
saved == nil || normalized != saved || !canSave
|
|
|| !PortForwardingEditorPolicy.canStart(
|
|
draft.id, savedRule: saved,
|
|
savedHost: app.hostLibrary.host(draft.hostID),
|
|
isRuntimeActive: runtime.isActive)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
PortForwardingFields(rule: $draft, hosts: app.forwardings.hosts)
|
|
.disabled(runtime.isActive)
|
|
if let error { Text(error).foregroundStyle(.red) }
|
|
if ruleID != nil && saved == nil {
|
|
Text("The forwarding rule is unavailable.").foregroundStyle(.secondary)
|
|
}
|
|
}.formStyle(.grouped)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(Motion.panel(reduceMotion: reduceMotion), value: context?.journey.isPresented ?? false)
|
|
} footer: {
|
|
HStack(spacing: Metrics.Space.m) {
|
|
if ruleID != nil {
|
|
Button("Delete", role: .destructive) { confirmsDeletion = true }
|
|
}
|
|
Spacer()
|
|
Button("Cancel", action: cancelEditing)
|
|
Button("Save", action: saveAndClose).buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.defaultAction).disabled(!canSave)
|
|
}.padding(Metrics.Space.l)
|
|
}
|
|
.onAppear {
|
|
guard !loaded else { return }
|
|
loaded = true
|
|
if let saved {
|
|
draft = saved
|
|
original = saved
|
|
}
|
|
editingToken = editingSession.register(
|
|
route: .forwardingEditor(ruleID), isDirty: { isDirty }
|
|
) { commitChanges() }
|
|
}
|
|
.onDisappear {
|
|
guard let editingToken else { return }
|
|
editingSession.unregister(editingToken)
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Forwarding Rule?", isPresented: $confirmsDeletion, titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive, action: delete)
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Any active forwarding session for this rule will be stopped.")
|
|
}
|
|
.alert(
|
|
"Operation Failed",
|
|
isPresented: Binding(get: { deletionError != nil }, set: { if !$0 { deletionError = nil } })
|
|
) {
|
|
Button("OK") { deletionError = nil }
|
|
} message: {
|
|
Text(deletionError ?? "")
|
|
}
|
|
}
|
|
|
|
private func journey(_ context: PortForwardingConnection) -> some View {
|
|
let promptID = context.authenticationPrompt?.id
|
|
let hostKeyRequestID = context.hostKeyRequestID
|
|
return ConnectionJourneyView(
|
|
journey: context.journey, authenticationPrompt: context.authenticationPrompt,
|
|
hostKey: context.connection.pendingHostKey,
|
|
submitAuthentication: { identity, username, password, passphrase, key, save in
|
|
guard let promptID else { return }
|
|
app.forwarding.submitAuthentication(
|
|
for: draft.id, promptID: promptID, identityID: identity,
|
|
username: username, password: password, passphrase: passphrase, keyID: key, save: save)
|
|
},
|
|
continueHostKey: {
|
|
guard let hostKeyRequestID else { return }
|
|
app.forwarding.acceptHostKey(
|
|
for: draft.id, requestID: hostKeyRequestID, observation: $0, remember: false)
|
|
},
|
|
trustHostKey: {
|
|
guard let hostKeyRequestID else { return }
|
|
app.forwarding.acceptHostKey(
|
|
for: draft.id, requestID: hostKeyRequestID, observation: $0, remember: true)
|
|
},
|
|
denyHostKey: cancel, retry: { app.forwarding.retry(draft.id) }, cancel: cancel,
|
|
keyboardAuthentication: context.connection.keyboardAuthentication, cancelKeyboardAuthentication: cancel,
|
|
isEmbedded: true)
|
|
}
|
|
private func cancel() {
|
|
app.forwarding.stop(draft.id)
|
|
window.closeVaultWork()
|
|
}
|
|
private func cancelEditing() {
|
|
editingSession.requestTransition(route: .forwardingEditor(ruleID), action: window.closeVaultWork)
|
|
}
|
|
private func commitChanges() -> Bool {
|
|
guard isDirty else { return true }
|
|
guard canSave, let normalized else {
|
|
error = String(localized: "Complete all required fields before saving.")
|
|
return false
|
|
}
|
|
do {
|
|
let saved = try app.forwardings.save(normalized)
|
|
draft = saved
|
|
original = saved
|
|
error = nil
|
|
window.selectVaultItem(.forwarding(saved.id), on: .portForwarding)
|
|
return true
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
private func saveAndClose() {
|
|
guard commitChanges() else { return }
|
|
window.selectVaultItem(.forwarding(draft.id), on: .portForwarding)
|
|
window.closeVaultWork()
|
|
}
|
|
private func delete() {
|
|
guard let ruleID else { return }
|
|
do {
|
|
try app.forwardings.delete(ruleID)
|
|
window.pruneVaultSelections(in: app.hostLibrary)
|
|
window.closeVaultWork()
|
|
} catch {
|
|
deletionError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|