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

286 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import UIKit
struct MobileForwardingView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
let app: MobileAppModel
@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 = "") {
self.app = app
_query = State(initialValue: initialQuery)
}
private var rules: [PortForwardingRule] { app.forwardingLibrary.search(query) }
var body: some View {
MobileCardPage {
if app.forwardingLibrary.rules.isEmpty {
ContentUnavailableView(
AppStrings.Forwarding.noPortForwarding.swiftUIKey, systemImage: "arrow.left.arrow.right",
description: Text(AppStrings.Forwarding.createAForwardingRuleThenStartItManually))
} else if rules.isEmpty {
ContentUnavailableView.search(text: query)
}
MobileCardGrid(compact: compactCards) {
ForEach(rules) { rule in
card(rule)
}
}
}
.navigationTitle(AppStrings.Navigation.portForwarding.swiftUIKey)
.searchable(text: $query.singleLineText)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(AppStrings.Forwarding.newForwarding.swiftUIKey, systemImage: "plus") {
editing = PortForwardingRule(hostID: UUID())
}
}
}
.navigationDestination(item: $showing) { id in
MobileForwardingDetailView(app: app, ruleID: id)
}
.sheet(item: $editing) { rule in
MobileForwardingEditor(app: app, rule: rule, isNew: app.forwardingLibrary.portForwarding(rule.id) == nil)
}
.alert(
AppStrings.Common.operationFailed,
isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })
) {
Button(AppStrings.Common.ok.swiftUIKey) { error = nil }
} message: {
Text(error ?? "")
}
}
private func card(_ rule: PortForwardingRule) -> some View {
let runtime = app.forwardings.state(for: rule.id)
return MobileActionCard(compact: compactCards) {
showing = rule.id
} content: {
MobileForwardingCardContent(
rule: rule,
hostName: app.library.host(rule.hostID)?.name
?? String(localized: AppStrings.Forwarding.hostUnavailable),
runtime: runtime, compact: compactCards)
} actions: {
if runtime.isActive {
Button(AppStrings.Common.stop.swiftUIKey, systemImage: "stop") { app.forwardings.stop(rule.id) }
} else {
Button(
runtime.isFailed ? AppStrings.Common.retry.swiftUIKey : AppStrings.Forwarding.start.swiftUIKey,
systemImage: "play"
) { start(rule) }.disabled(
!canStart(rule))
}
Button(AppStrings.Forwarding.copyAddress.swiftUIKey, systemImage: "doc.on.doc") {
UIPasteboard.general.string = rule.bindAddress
}
Button(AppStrings.Vault.copySshCommand.swiftUIKey, systemImage: "apple.terminal") {
UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule)
}
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
Divider()
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = rule }.disabled(
runtime.isActive)
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") { duplicate(rule) }
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { removing = rule }
}
.operationConfirmation(
.init(AppStrings.Forwarding.deleteForwardingRulesConfirmation),
isPresented: $removing.isPresenting { $0.id == rule.id },
actions: [
.init(AppStrings.Common.delete, role: .destructive) {
guard let removing else { return }
do { try app.forwardingLibrary.delete(removing.id) } catch {
self.error = error.localizedDescription
}
self.removing = nil
},
.init(AppStrings.Common.cancel, role: .cancel) { removing = nil },
]
)
}
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: AppStrings.Common.copySuffix) }
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 compact = false
var body: some View {
if compact {
MobileCompactCardContent(
title: rule.displayName(hostName: hostName), status: Text(verbatim: runtime.label)
) {
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
.overlay(alignment: .bottomTrailing) {
if let badge = compactBadge {
badge
}
}
}
} else {
standardContent
}
}
var compactBadge: MobileCompactCardBadge? {
switch runtime {
case .stopped: nil
case .running: MobileCompactCardBadge(symbol: "checkmark.circle.fill", tint: .green)
case .failed: MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill", tint: .red)
default: MobileCompactCardBadge(symbol: "clock.fill")
}
}
private var standardContent: 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(AppStrings.Common.status.swiftUIKey, value: runtime.label)
}
Section(AppStrings.Navigation.portForwarding.swiftUIKey) {
LabeledContent(
AppStrings.Vault.host.swiftUIKey,
value: app.library.host(rule.hostID)?.name
?? String(localized: AppStrings.Forwarding.hostUnavailable))
LabeledContent(AppStrings.Forwarding.direction.swiftUIKey, value: rule.kind.displayName)
Text(rule.summary).font(.body.monospaced()).textSelection(.enabled)
Button(AppStrings.Forwarding.copyAddress.swiftUIKey) {
UIPasteboard.general.string = rule.bindAddress
}
Button(AppStrings.Vault.copySshCommand.swiftUIKey) {
UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule)
}
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
}
}
}
} else {
ContentUnavailableView(
AppStrings.Forwarding.theForwardingRuleIsUnavailable.swiftUIKey,
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(AppStrings.Common.stop.swiftUIKey, systemImage: "stop") { app.forwardings.stop(ruleID) }
} else if let rule {
Button(
runtime.isFailed
? AppStrings.Common.retry.swiftUIKey : AppStrings.Forwarding.start.swiftUIKey,
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(AppStrings.Common.edit.swiftUIKey, 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,
securityKeySigning: context.connection.securityKeySigning,
keyboardAuthentication: context.connection.keyboardAuthentication, cancelKeyboardAuthentication: cancel)
}
private func cancel() {
app.forwardings.stop(ruleID)
dismiss()
}
}