Refactor Port Forwarding

This commit is contained in:
Kaede Akino committed 2026-09-18 23:37:57 +08:00
1 parent 32aed0a610
commit 61cde4cd40
61 files changed
+2381 -1198

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct ForwardingColorTests {
@Test func legacyRulesDecodeWithoutColorAndNewRulesRoundTripEveryColor() throws {
var rule = PortForwardingRule(hostID: UUID(), label: "Database")
var legacy = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(rule)) as? [String: Any])
legacy.removeValue(forKey: "colorTag")
#expect(
try JSONDecoder().decode(PortForwardingRule.self, from: JSONSerialization.data(withJSONObject: legacy))
== rule)
legacy["colorTag"] = "future-color"
#expect(
try JSONDecoder().decode(PortForwardingRule.self, from: JSONSerialization.data(withJSONObject: legacy))
== rule)
for color in TagColor.allCases {
rule.colorTag = color
#expect(try JSONDecoder().decode(PortForwardingRule.self, from: JSONEncoder().encode(rule)) == rule)
}
}
@Test func colorEditsRemainDraftsUntilSavedAndSyncCanSetAndClearThem() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = HostPersistence(context: container.mainContext)
let library = HostLibraryModel(persistence: persistence)
let model = ForwardingLibraryModel(library: library)
let host = Host(name: "Server", host: "server.invalid")
library.upsert(host)
let original = PortForwardingRule(hostID: host.id, colorTag: .teal)
try model.save(original)
var draft = original
draft.colorTag = .pink
#expect(model.portForwarding(original.id)?.colorTag == .teal)
try model.save(draft)
#expect(try persistence.load().portForwardings == [draft])
let remoteContainer = try TermindPersistence.makeContainer(inMemory: true)
let remote = HostPersistence(context: remoteContainer.mainContext)
let hostPayload = VaultRecordPayload.host(host)
_ = try remote.applyRemote([
VaultEnvelope(key: hostPayload.key, version: .baseline(writerID: UUID()), payload: hostPayload)
])
for (index, color) in [TagColor.pink, .none].enumerated() {
draft.colorTag = color
let payload = VaultRecordPayload.portForwarding(draft)
let envelope = VaultEnvelope(
key: payload.key,
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: Double(index + 1))),
payload: payload)
let received = try JSONDecoder().decode(VaultEnvelope.self, from: JSONEncoder().encode(envelope))
_ = try remote.applyRemote([received])
#expect(try remote.load().portForwardings == [draft])
}
}
@Test func version19RulesMigrateWithoutChangingIDsHostsOrParameters() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
let host = Host(name: "Server", host: "server.invalid")
let rules = PortForwardingRule.Kind.allCases.map {
PortForwardingRule(hostID: host.id, kind: $0, bindHost: "::1", bindPort: 15432, label: "Database")
}
do {
let schema = Schema(versionedSchema: TermindSchemaV19.self)
let legacy = try ModelContainer(
for: schema,
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
legacy.mainContext.insert(PersistentHost(host))
for rule in rules {
legacy.mainContext.insert(TermindSchemaV19ForwardingModels.PersistentPortForwardingRule(rule))
}
try legacy.mainContext.save()
}
let container = try TermindPersistence.makeContainer(url: url)
let persistence = HostPersistence(context: container.mainContext)
let snapshot = try persistence.load()
#expect(snapshot.hosts == [host])
#expect(Set(snapshot.portForwardings) == Set(rules))
let model = ForwardingLibraryModel(library: HostLibraryModel(persistence: persistence))
var colored = rules[0]
colored.colorTag = .purple
try model.save(colored)
let reopened = try TermindPersistence.makeContainer(url: url)
#expect(
try HostPersistence(context: reopened.mainContext).load().portForwardings.first { $0.id == colored.id }
== colored)
}
}
@@ -0,0 +1,94 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct ForwardingLibraryTests {
@Test func rulesSaveRebindAndDeleteWithoutRewritingHosts() throws {
let first = Host(name: "First", host: "first.invalid")
let second = Host(name: "Second", host: "second.invalid")
let store = HostLibraryModel(hosts: [first, second])
let model = ForwardingLibraryModel(library: store)
var rule = PortForwardingRule(hostID: first.id, label: "Database")
try model.save(rule)
let savedHosts = store.hosts
rule.hostID = second.id
try model.save(rule)
#expect(model.rules == [rule])
#expect(store.hosts == savedHosts)
#expect(store.portForwardings(for: first.id).isEmpty)
#expect(model.search("second").map(\.id) == [rule.id])
#expect(model.search("Database").map(\.id) == [rule.id])
#expect(model.search("8080").map(\.id) == [rule.id])
try model.delete(rule.id)
#expect(model.rules.isEmpty)
#expect(store.hosts == savedHosts)
}
@Test func failedSavesAndDeletesRollbackTheRuleSnapshot() throws {
let host = Host(name: "Server", host: "server.invalid")
let other = Host(name: "Other", host: "other.invalid")
let rule = PortForwardingRule(hostID: host.id)
let persistence = ForwardingPersistence(snapshot: VaultSnapshot(hosts: [host, other], portForwardings: [rule]))
let store = HostLibraryModel(persistence: persistence)
let model = ForwardingLibraryModel(library: store)
persistence.fails = true
var changed = rule
changed.hostID = other.id
changed.bindPort = 9000
changed.colorTag = .orange
#expect(throws: (any Error).self) { try model.save(changed) }
#expect(model.rules == [rule])
#expect(persistence.snapshot.portForwardings == [rule])
#expect(store.hosts == [host, other])
#expect(throws: (any Error).self) { try model.delete(rule.id) }
#expect(model.rules == [rule])
let new = PortForwardingRule(hostID: host.id, bindPort: 9090)
#expect(throws: (any Error).self) { try model.save(new) }
#expect(model.rules == [rule])
persistence.fails = false
try model.save(changed)
#expect(model.rules == [changed])
}
@Test func invalidHostIsRejectedAndOrphansRemainDeletable() throws {
let orphan = PortForwardingRule(hostID: UUID())
let store = HostLibraryModel(portForwardings: [orphan])
let model = ForwardingLibraryModel(library: store)
#expect(throws: (any Error).self) { try model.save(orphan) }
try model.delete(orphan.id)
#expect(model.rules.isEmpty)
}
@Test func hostSaveFailurePreservesItsConfigurationAndRules() {
var host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let persistence = ForwardingPersistence(snapshot: VaultSnapshot(hosts: [host], portForwardings: [rule]))
let store = HostLibraryModel(persistence: persistence)
persistence.fails = true
host.port = 2222
#expect(!store.saveHost(host))
#expect(store.host(host.id)?.port == 22)
#expect(store.portForwardings == [rule])
}
}
@MainActor
private final class ForwardingPersistence: VaultPersisting {
var snapshot: VaultSnapshot
var fails = false
init(snapshot: VaultSnapshot) { self.snapshot = snapshot }
func load() throws -> VaultSnapshot { snapshot }
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
if fails { throw CocoaError(.fileWriteUnknown) }
self.snapshot = snapshot
return .empty
}
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {}
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
}
}
@@ -65,7 +65,7 @@ struct PersistenceCompatibilityTests {
try first.mainContext.save()
#expect(try second.mainContext.fetchCount(FetchDescriptor<PersistentHostGroup>()) == 0)
#expect(Set(first.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
#expect(TermindMigrationPlan.schemas.count == 19)
#expect(TermindMigrationPlan.schemas.count == 20)
#expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" })
}
@@ -7,31 +7,15 @@ import struct TermindDomain.Host
@Suite
struct PortForwardingEditorPolicyTests {
@Test(
arguments: [false, true],
[
(false, false, true, true, false),
(true, false, false, true, true),
(false, true, false, false, false),
(true, true, false, false, false),
])
func controlsKeepSavedEligibilityIntentAndActivityIndependent(
hasSavedConfiguration: Bool,
scenario: (enabled: Bool, active: Bool, canStartSaved: Bool, canEdit: Bool, waitsWhenUnsaved: Bool)
) {
@Test(arguments: [false, true], [false, true])
func onlySavedStoppedRulesCanStart(hasSavedConfiguration: Bool, isActive: Bool) {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let policy = PortForwardingEditorPolicy(
rule: rule, savedRule: hasSavedConfiguration ? rule : nil, savedHost: host,
isEnabled: scenario.enabled, isRuntimeActive: scenario.active)
#expect(policy.hasSavedConfiguration == hasSavedConfiguration)
#expect(policy.isEnabled == scenario.enabled)
#expect(policy.isRuntimeActive == scenario.active)
#expect(policy.canStart == (hasSavedConfiguration && scenario.canStartSaved))
#expect(policy.canToggle)
#expect(policy.canEdit == scenario.canEdit)
#expect(policy.isWaitingForSave == (!hasSavedConfiguration && scenario.waitsWhenUnsaved))
isRuntimeActive: isActive)
#expect(policy.canStart == (hasSavedConfiguration && !isActive))
#expect(policy.canEdit == !isActive)
}
enum UnavailableConfiguration: CaseIterable {
@@ -52,11 +36,10 @@ struct PortForwardingEditorPolicyTests {
}
let policy = PortForwardingEditorPolicy(
rule: rule, savedRule: savedRule, savedHost: savedHost,
isEnabled: false, isRuntimeActive: false)
isRuntimeActive: false)
#expect(!policy.hasSavedConfiguration)
#expect(!policy.canStart)
#expect(policy.canToggle)
#expect(policy.canEdit)
}
@@ -68,7 +51,7 @@ struct PortForwardingEditorPolicyTests {
savedRule.bindPort = invalidPort
let policy = PortForwardingEditorPolicy(
rule: PortForwardingEditorPolicy.normalized(draft), savedRule: savedRule, savedHost: host,
isEnabled: false, isRuntimeActive: false)
isRuntimeActive: false)
#expect(!policy.hasSavedConfiguration)
#expect(!policy.canStart)
+202 -1
View File
@@ -66,6 +66,24 @@
}
}
},
"%@" : {
"comment" : "The title of the vault, when creating a new forwarding rule.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
}
}
},
"%@ — %@" : {
"localizations" : {
"en" : {
@@ -2116,6 +2134,23 @@
}
}
},
"Add a host before creating a forwarding rule." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请先添加主机,再创建转发规则。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "請先加入主機,再建立轉送規則。"
}
}
}
},
"Add a host to connect to it." : {
"localizations" : {
"zh-Hans" : {
@@ -2329,6 +2364,7 @@
}
},
"Add Forwarding" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -6946,6 +6982,22 @@
}
}
},
"Create a forwarding rule, then start it manually." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "创建转发规则后,手动启动转发。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "建立轉送規則後,手動啟動轉送。"
}
}
}
},
"Created directories" : {
"extractionState" : "manual",
"localizations" : {
@@ -7421,6 +7473,24 @@
}
}
},
"Delete Forwarding Rules?" : {
"comment" : "A confirmation dialog that asks the user to delete a set of forwarding rules.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "删除转发规则?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "刪除轉送規則?"
}
}
}
},
"Delete Group" : {
"localizations" : {
"zh-Hans" : {
@@ -8125,7 +8195,6 @@
}
},
"Direction" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -8177,6 +8246,7 @@
},
"Disable" : {
"comment" : "A button that disables a forwarding rule.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -8195,6 +8265,7 @@
},
"Disable All" : {
"comment" : "A button that disables all running port forwarding rules.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -8857,6 +8928,7 @@
}
},
"Dynamic proxy (-D)" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -9282,6 +9354,7 @@
},
"Enable All" : {
"comment" : "A button that enables all forwarding rules.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -9349,6 +9422,7 @@
},
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved." : {
"comment" : "A description of how changes to forwarding rules are saved.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -10759,6 +10833,23 @@
}
}
},
"Forwarding ready" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "转发已就绪"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "轉送已就緒"
}
}
}
},
"Free" : {
"comment" : "A label for a free subscription.",
"isCommentAutoGenerated" : true,
@@ -11670,6 +11761,24 @@
}
}
},
"Host Unavailable" : {
"comment" : "A label for the host of a port forwarding rule.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "主机不可用"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "主機無法使用"
}
}
}
},
"Host: %@" : {
"extractionState" : "stale",
"localizations" : {
@@ -13666,6 +13775,7 @@
}
},
"Local forward (-L)" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -15560,6 +15670,24 @@
}
}
},
"No Port Forwarding" : {
"comment" : "A title for a view that shows when an app has no forwarding rules.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "没有端口转发"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "沒有連接埠轉送"
}
}
}
},
"No Providers" : {
"localizations" : {
"zh-Hans" : {
@@ -19350,6 +19478,7 @@
}
},
"Remote forward (-R)" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -21135,6 +21264,23 @@
}
}
},
"Select Host" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择主机"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選擇主機"
}
}
}
},
"Select None" : {
"localizations" : {
"zh-Hans" : {
@@ -22577,6 +22723,23 @@
}
}
},
"SSH %@ → This Device %@" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH %1$@→本机%2$@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH %1$@→本機%2$@"
}
}
}
},
"SSH %@ → This Mac %@" : {
"extractionState" : "stale",
"localizations" : {
@@ -22766,6 +22929,24 @@
}
}
},
"Start" : {
"comment" : "A button that starts a forwarding rule.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启动"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "啟動"
}
}
}
},
"Start a Conversation" : {
"localizations" : {
"zh-Hans" : {
@@ -24343,6 +24524,24 @@
}
}
},
"The forwarding rule is unavailable." : {
"comment" : "A message that is displayed when a forwarding rule is unavailable.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "转发规则不可用。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "轉送規則無法使用。"
}
}
}
},
"The forwarding SSH connection closed" : {
"extractionState" : "stale",
"localizations" : {
@@ -26169,6 +26368,7 @@
},
"This forwarding will start after the host is saved." : {
"comment" : "A description of the state of a forwarding that is waiting for the host to be saved.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -28683,6 +28883,7 @@
},
"Waiting for Save" : {
"comment" : "A label displayed in a forwarding's row that indicates that the forwarding is waiting for the host to be saved.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -8,6 +8,7 @@ public nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
case sftpSession
case serialSession
case metrics
case portForwarding
}
public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
@@ -334,6 +335,7 @@ extension ConnectionPurpose {
case .sftpSession: "SFTP session"
case .serialSession: "serial session"
case .metrics: "metrics"
case .portForwarding: "port forwarding"
}
}
}
@@ -16,22 +16,31 @@ public final class ForwardingLibraryModel {
public var rules: [PortForwardingRule] { library.portForwardings }
public func save(_ rule: PortForwardingRule) throws {
guard rule.isValid, let host = library.host(rule.hostID) else { throw CocoaError(.coderInvalidValue) }
let rules = library.portForwardings(for: host.id).filter { $0.id != rule.id } + [rule]
guard library.upsert(host, replacingPortForwardings: rules) else {
guard library.upsert(rule) else {
throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save"))
}
}
public func delete(_ id: UUID) throws {
guard let rule = library.portForwarding(id), let host = library.host(rule.hostID) else { return }
guard
library.upsert(host, replacingPortForwardings: library.portForwardings(for: host.id).filter { $0.id != id })
else {
public func delete(_ id: UUID) throws { try delete([id]) }
public func delete(_ ids: Set<UUID>) throws {
guard library.deletePortForwardings(ids) else {
throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save"))
}
}
public func search(_ query: String) -> [PortForwardingRule] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return rules }
return rules.filter { rule in
[
rule.label, rule.bindAddress, rule.targetHost ?? "", rule.targetPort.map(String.init) ?? "",
library.host(rule.hostID)?.name ?? "", library.host(rule.hostID)?.displayAddress ?? "",
]
.contains { $0.localizedCaseInsensitiveContains(query) }
}
}
public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? {
library.portForwarding(id)
}
@@ -30,9 +30,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// references are removed and the controller can stop listeners cleanly.
public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)?
/// Reconciles locally enabled rules after their saved configuration has been added,
/// replaced, removed, or rolled back.
public var didReplacePortForwardings: (([PortForwardingRule.ID]) -> Void)?
public var didChangeHostConnection: ((Host.ID) -> Void)?
public var didPersistCore: (() -> Void)? {
get { vault.didPersist }
set { vault.didPersist = newValue }
@@ -827,71 +825,16 @@ public final class HostLibraryModel: RemoteSessionHostStore {
// MARK: - Writes
public func upsert(_ host: Host) {
guard validatedRoute(for: host) != nil else {
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
return
}
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
var updated = host
updated.detectedDistribution = hosts[index].detectedDistribution
hosts[index] = updated
} else {
var new = host
new.sortIndex = nextSortIndex(in: host.groupID)
hosts.append(new)
}
persistCore()
}
public func upsert(_ host: Host) { _ = saveHost(host) }
/// Commits one host and its complete editor-owned collections together.
///
/// `HostEditorView` owns the Host, forwarding rules, and Known Hosts as
/// drafts, so persisting them through separate calls would let Save only
/// partly succeed. The in-memory projections are
/// restored if SwiftData rejects the replacement, and rules whose live
/// connection would no longer match the saved configuration are stopped
/// before their old records disappear.
@discardableResult
public func upsert(
_ host: Host,
replacingPortForwardings rules: [PortForwardingRule]
) -> Bool {
public func saveHost(_ host: Host) -> Bool {
guard validatedRoute(for: host) != nil else {
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
return false
}
let normalizedRules = rules.map { rule in
var normalized = rule
normalized.hostID = host.id
if normalized.kind == .dynamic {
normalized.targetHost = nil
normalized.targetPort = nil
}
return normalized
}
guard normalizedRules.allSatisfy(\.isValid),
Set(normalizedRules.map(\.id)).count == normalizedRules.count
else {
storageError = String(localized: "One or more forwarding rules are incomplete.")
return false
}
let oldHosts = hosts
let oldRules = portForwardings
let existingHost = self.host(host.id)
let existingRules = portForwardings(for: host.id)
let existingRuleIDs = Set(existingRules.map(\.id))
let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) })
var rulesToStop = Set(
existingRules.compactMap { old in nextByID[old.id] == old ? nil : old.id }
)
if let existingHost, existingHost.sshConnectionFields != host.sshConnectionFields {
rulesToStop.formUnion(existingRules.map(\.id))
}
let affectedRuleIDs = Array(rulesToStop)
if !affectedRuleIDs.isEmpty { willDeletePortForwardings?(affectedRuleIDs) }
let previous = hosts
let old = self.host(host.id)
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
var updated = host
updated.detectedDistribution = hosts[index].detectedDistribution
@@ -901,17 +844,11 @@ public final class HostLibraryModel: RemoteSessionHostStore {
new.sortIndex = nextSortIndex(in: host.groupID)
hosts.append(new)
}
portForwardings.removeAll { $0.hostID == host.id }
portForwardings.append(contentsOf: normalizedRules)
guard persistCore() else {
hosts = oldHosts
portForwardings = oldRules
if !affectedRuleIDs.isEmpty { didReplacePortForwardings?(affectedRuleIDs) }
hosts = previous
return false
}
let addedRuleIDs = Set(normalizedRules.map(\.id)).subtracting(existingRuleIDs)
let savedRuleIDs = Array(rulesToStop.union(addedRuleIDs))
if !savedRuleIDs.isEmpty { didReplacePortForwardings?(savedRuleIDs) }
if let old, !old.hasSameConnectionRoute(as: host) { didChangeHostConnection?(host.id) }
return true
}
@@ -1523,19 +1460,39 @@ public final class HostLibraryModel: RemoteSessionHostStore {
persistCore()
}
public func upsert(_ forwarding: PortForwardingRule) {
guard forwarding.isValid, host(forwarding.hostID) != nil else { return }
var normalized = forwarding
if normalized.kind == .dynamic {
normalized.targetHost = nil
normalized.targetPort = nil
@discardableResult
public func upsert(_ forwarding: PortForwardingRule) -> Bool {
guard let normalized = PortForwardingEditorPolicy.normalized(forwarding), host(forwarding.hostID) != nil else {
storageError = String(localized: "The forwarding rule is incomplete.")
return false
}
let previous = portForwardings
if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) {
if portForwardings[index] == normalized { return true }
willDeletePortForwardings?([normalized.id])
portForwardings[index] = normalized
} else {
portForwardings.append(normalized)
}
persistCore()
guard persistCore() else {
portForwardings = previous
return false
}
return true
}
@discardableResult
public func deletePortForwardings(_ ids: Set<PortForwardingRule.ID>) -> Bool {
let affected = portForwardings.filter { ids.contains($0.id) }.map(\.id)
guard !affected.isEmpty else { return true }
let previous = portForwardings
willDeletePortForwardings?(affected)
portForwardings.removeAll { ids.contains($0.id) }
guard persistCore() else {
portForwardings = previous
return false
}
return true
}
/// Root jump first and destination last. A nil result means the proposed
@@ -1912,23 +1869,6 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
}
extension Host {
fileprivate var sshConnectionFields: SSHConnectionFields {
SSHConnectionFields(
host: host, port: port, identityID: identityID,
jumpHostID: jumpHostID, proxy: proxy
)
}
}
private struct SSHConnectionFields: Equatable {
let host: String
let port: Int
let identityID: Identity.ID?
let jumpHostID: Host.ID?
let proxy: HostProxy?
}
// MARK: - Manual ordering
/// A deterministic tie-break keeps hand-built preview data usable before its
@@ -6,23 +6,19 @@ import struct TermindDomain.Host
/// Shared edit and start policy for the platform-specific forwarding editors.
public nonisolated struct PortForwardingEditorPolicy {
public let hasSavedConfiguration: Bool
public let isEnabled: Bool
public let isRuntimeActive: Bool
public var canStart: Bool { hasSavedConfiguration && !isEnabled && !isRuntimeActive }
public var canToggle: Bool { true }
public var canStart: Bool { hasSavedConfiguration && !isRuntimeActive }
public var canEdit: Bool { !isRuntimeActive }
public var isWaitingForSave: Bool { isEnabled && !hasSavedConfiguration && !isRuntimeActive }
public init(
rule: PortForwardingRule?,
savedRule: PortForwardingRule?, savedHost: Host?,
isEnabled: Bool, isRuntimeActive: Bool
isRuntimeActive: Bool
) {
hasSavedConfiguration =
savedRule?.isValid == true && savedRule?.hostID == savedHost?.id
&& (rule == nil || rule?.id == savedRule?.id)
self.isEnabled = isEnabled
self.isRuntimeActive = isRuntimeActive
}
@@ -336,13 +336,4 @@ public nonisolated enum TermindSSHError: LocalizedError, Sendable {
return false
}
public var isRetryableForwardingFailure: Bool {
switch self {
case .socket, .library, .handshake, .disconnected: true
case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged,
.missingCredential, .keyPassphraseRequired, .missingUsername,
.untrustedHost, .mismatchedHostKey:
false
}
}
}
@@ -19,8 +19,6 @@ public nonisolated enum SettingsKey {
public static let iCloudSyncEnabled = "icloud.sync.enabled"
public static let metricsSyncEnabled = "metrics.sync.enabled"
public static let metricsSyncEnvironment = "metrics.sync.environment"
/// Device-local forwarding intent. It is deliberately excluded from Settings and Vault sync.
public static let portForwardingEnabledRuleIDs = "portForwarding.enabledRuleIDs"
public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
public static let settingsSyncEnabled = "settings.sync.enabled"
public static let settingsSyncEnvironment = "settings.sync.environment"
@@ -14,12 +14,13 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
public var targetHost: String? = "localhost"
public var targetPort: Int? = 80
public var label: String = ""
public var colorTag: TagColor = .none
public init(
id: UUID = UUID(), hostID: Host.ID, kind: Kind = .local,
bindHost: String = "127.0.0.1", bindPort: Int = 8080,
targetHost: String? = "localhost", targetPort: Int? = 80,
label: String = ""
label: String = "", colorTag: TagColor = .none
) {
self.id = id
self.hostID = hostID
@@ -29,6 +30,25 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
self.targetHost = kind == .dynamic ? nil : targetHost
self.targetPort = kind == .dynamic ? nil : targetPort
self.label = label
self.colorTag = colorTag
}
private enum CodingKeys: String, CodingKey {
case id, hostID, kind, bindHost, bindPort, targetHost, targetPort, label, colorTag
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try values.decode(UUID.self, forKey: .id),
hostID: try values.decode(UUID.self, forKey: .hostID),
kind: try values.decode(Kind.self, forKey: .kind),
bindHost: try values.decode(String.self, forKey: .bindHost),
bindPort: try values.decode(Int.self, forKey: .bindPort),
targetHost: try values.decodeIfPresent(String.self, forKey: .targetHost),
targetPort: try values.decodeIfPresent(Int.self, forKey: .targetPort),
label: try values.decode(String.self, forKey: .label),
colorTag: (try? values.decode(TagColor.self, forKey: .colorTag)) ?? .none)
}
public var isValid: Bool {
@@ -47,7 +67,8 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
case .local:
"\(bindAddress) → \(Self.address(targetHost ?? "", port: targetPort ?? 0))"
case .remote:
String(localized: "SSH \(bindAddress) → This Mac \(Self.address(targetHost ?? "", port: targetPort ?? 0))")
String(
localized: "SSH \(bindAddress) → This Device \(Self.address(targetHost ?? "", port: targetPort ?? 0))")
case .dynamic: "SOCKS5 \(bindAddress)"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import Foundation
/// A persisted label color shared by hosts, keys, and identities.
/// A persisted label color shared by hosts, keys, identities, and forwarding rules.
/// Presentation details live in TermindPresentation so the domain value remains
/// usable without SwiftUI.
public nonisolated enum TagColor: String, CaseIterable, Codable, Sendable, Identifiable {
@@ -426,6 +426,7 @@ public final class PersistentPortForwardingRule {
public var targetHost: String?
public var targetPort: Int?
public var label = ""
public var colorTag = TagColor.none.rawValue
public init(_ value: PortForwardingRule) {
id = value.id
@@ -440,6 +441,7 @@ public final class PersistentPortForwardingRule {
targetHost = value.kind == .dynamic ? nil : value.targetHost
targetPort = value.kind == .dynamic ? nil : value.targetPort
label = value.label
colorTag = value.colorTag.rawValue
}
public var value: PortForwardingRule {
@@ -447,7 +449,8 @@ public final class PersistentPortForwardingRule {
id: id, hostID: hostID,
kind: PortForwardingRule.Kind(rawValue: kind) ?? .local,
bindHost: bindHost, bindPort: bindPort,
targetHost: targetHost, targetPort: targetPort, label: label
targetHost: targetHost, targetPort: targetPort, label: label,
colorTag: TagColor(rawValue: colorTag) ?? .none
)
}
}
@@ -503,7 +506,7 @@ public enum TermindSchemaV1: VersionedSchema {
PersistentKnownHost.self,
PersistentSnippetPackage.self,
PersistentSnippet.self,
PersistentPortForwardingRule.self,
TermindSchemaV19ForwardingModels.PersistentPortForwardingRule.self,
PersistentOutboxJob.self,
PersistentPendingDeletion.self,
PersistentDatabaseMetadata.self,
@@ -693,6 +696,15 @@ public enum TermindSchemaV19: VersionedSchema {
}
}
public enum TermindSchemaV20: VersionedSchema {
public static let versionIdentifier = Schema.Version(20, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV19.models.filter {
$0 != TermindSchemaV19ForwardingModels.PersistentPortForwardingRule.self
} + [PersistentPortForwardingRule.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -701,7 +713,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self,
TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self,
TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self,
TermindSchemaV18.self, TermindSchemaV19.self,
TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self,
]
}
@@ -762,6 +774,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV16.self, toVersion: TermindSchemaV17.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV17.self, toVersion: TermindSchemaV18.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV18.self, toVersion: TermindSchemaV19.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV19.self, toVersion: TermindSchemaV20.self),
]
}
}
@@ -5,7 +5,7 @@ import SwiftData
/// choose storage and own the container lifetime, never the model set.
@MainActor
public enum TermindPersistence {
public static let schema = Schema(versionedSchema: TermindSchemaV19.self)
public static let schema = Schema(versionedSchema: TermindSchemaV20.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,29 @@
import Foundation
import SwiftData
import TermindDomain
/// Freeze the V1–V19 forwarding entity before adding its optional color in V20.
public enum TermindSchemaV19ForwardingModels {
@Model
public final class PersistentPortForwardingRule {
@Attribute(.unique) public var id: UUID = UUID()
public var hostID: UUID = UUID()
public var kind = PortForwardingRule.Kind.local.rawValue
public var bindHost = "127.0.0.1"
public var bindPort = 8080
public var targetHost: String?
public var targetPort: Int?
public var label = ""
public init(_ value: PortForwardingRule) {
id = value.id
hostID = value.hostID
kind = value.kind.rawValue
bindHost = value.bindHost
bindPort = value.bindPort
targetHost = value.kind == .dynamic ? nil : value.targetHost
targetPort = value.kind == .dynamic ? nil : value.targetPort
label = value.label
}
}
}
@@ -1,7 +1,7 @@
import SwiftUI
import TermindDomain
/// The persisted tag palette, shared by host, identity and key editors on both platforms.
/// The persisted tag palette shared by Vault editors on both platforms.
public struct ColorTagPicker: View {
public enum Layout { case compact, touch }
@Binding private var selection: TagColor
@@ -184,6 +184,7 @@ private struct JourneyEndpointView: View {
case .sshSession: "apple.terminal.fill"
case .sftpSession: "folder.fill"
case .metrics: "waveform.path.ecg"
case .portForwarding: "arrow.left.arrow.right"
case .serialSession: "cable.connector"
}
}
@@ -63,7 +63,7 @@ public struct ConnectionJourneyHeader: View {
private var detail: String {
let protocolName: String =
switch journey.purpose {
case .sshSession, .metrics: "SSH"
case .sshSession, .metrics, .portForwarding: "SSH"
case .sftpSession: "SFTP"
case .serialSession: String(localized: "Serial")
}
@@ -80,6 +80,7 @@ public struct ConnectionJourneyHeader: View {
case .sftpSession: "folder.fill.badge.gearshape"
case .serialSession: "cable.connector"
case .metrics: "waveform.path.ecg"
case .portForwarding: "arrow.left.arrow.right"
}
}
}
@@ -112,6 +113,7 @@ public struct ConnectionJourneyStatus: View {
case .sshSession, .serialSession: String(localized: "Terminal ready")
case .sftpSession: String(localized: "File browser ready")
case .metrics: String(localized: "Metrics ready")
case .portForwarding: String(localized: "Forwarding ready")
}
case .failed(let message): message
case .hidden: ""
@@ -11,6 +11,16 @@ public struct PortForwardingFields: View {
.textFieldStyle(
.automatic
).multilineTextAlignment(.leading)
#if os(macOS)
LabeledContent("Color") {
ColorTagPicker(selection: $rule.colorTag, layout: .compact)
}
#else
VStack(alignment: .leading, spacing: 12) {
Text("Color")
ColorTagPicker(selection: $rule.colorTag, layout: .touch)
}
#endif
PortForwardingDirectionPicker(kind: $rule.kind)
Text(directionDescription).font(.caption).foregroundStyle(.secondary)
LabeledContent("Bind Address") {
@@ -0,0 +1,34 @@
import SwiftUI
import TermindDomain
import struct TermindDomain.Host
public struct PortForwardingHostPicker: View {
@Binding private var rule: PortForwardingRule
private let hosts: [Host]
public init(rule: Binding<PortForwardingRule>, hosts: [Host]) {
_rule = rule
self.hosts = hosts
}
public var body: some View {
Section("Host") {
Picker("Host", selection: selection) {
Text("Select Host").tag(UUID?.none)
ForEach(hosts) { host in
Text("\(host.name) · \(host.displayAddress)").tag(Optional(host.id))
}
}
if hosts.isEmpty {
Text("Add a host before creating a forwarding rule.").font(.caption).foregroundStyle(.secondary)
}
}
}
private var selection: Binding<UUID?> {
Binding(
get: { hosts.contains { $0.id == rule.hostID } ? rule.hostID : nil },
set: { rule.hostID = $0 ?? UUID() })
}
}
@@ -0,0 +1,44 @@
import Foundation
import TermindDomain
extension PortForwardingRule {
public nonisolated func displayName(hostName: String?) -> String {
let name = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard name.isEmpty else { return name }
return "\(hostName ?? String(localized: "Host Unavailable")) (\(kind.sshArgument))"
}
}
extension PortForwardingRule.Kind {
public nonisolated var sshArgument: String {
switch self {
case .local: "-L"
case .remote: "-R"
case .dynamic: "-D"
}
}
public nonisolated var shortDisplayName: String {
switch self {
case .local: String(localized: "Local")
case .remote: String(localized: "Remote")
case .dynamic: String(localized: "Dynamic")
}
}
public nonisolated var displayName: String {
switch self {
case .local: String(localized: "Local forward (-L)")
case .remote: String(localized: "Remote forward (-R)")
case .dynamic: String(localized: "Dynamic proxy (-D)")
}
}
public nonisolated var symbol: String {
switch self {
case .local: "arrow.right"
case .remote: "arrow.left"
case .dynamic: "shuffle"
}
}
}
@@ -12,18 +12,20 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
case connecting
case starting
case running(activeConnections: Int)
case reconnecting(attempt: Int, nextRetry: Date, message: String)
case failed(String)
public var isActive: Bool {
switch self {
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
.starting, .running, .reconnecting:
true
case .stopped, .failed: false
default: true
}
}
public var isFailed: Bool {
if case .failed = self { return true }
return false
}
public var label: String {
switch self {
case .stopped: String(localized: "Stopped")
@@ -38,336 +40,375 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
: count == 1
? String(localized: "Running · 1 connection")
: String(localized: "Running · \(count) connections")
case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)")
case .failed(let message): message
}
}
}
/// Keeps device-local enabled intent separate from process-local SSH resources.
/// Enabled rule IDs are not part of Vault or Settings sync.
/// A host's manually opened transport and its shared authentication surface.
@Observable
public final class PortForwardingConnection {
public let hostID: Host.ID
public let connection = RemoteSessionConnectionController()
public var journey: ConnectionJourney { connection.journey }
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
public fileprivate(set) var hostKeyRequestID: UUID?
fileprivate var route: [Host]
fileprivate var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
fileprivate var attemptID: UUID?
fileprivate var session: (any SSHForwardingSessionProtocol)?
fileprivate var task: Task<Void, Never>?
fileprivate var connected = false
fileprivate init(host: Host, route: [Host]) {
hostID = host.id
self.route = route
}
fileprivate func setPrompt(_ prompt: SSHAuthenticationPrompt?) { authenticationPrompt = prompt }
}
/// Runtime intent exists only between explicit Start and Stop/failure. No defaults or retry timers.
@Observable
public final class PortForwardingController {
public typealias SessionFactory =
@MainActor (
@escaping SSHForwardingSession.ConnectionHandler, @escaping SSHForwardingSession.FailureHandler
) -> any SSHForwardingSessionProtocol
private let hosts: HostLibraryModel
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
private let defaults: UserDefaults?
private let enabledDefaultsKey: String
private let makeSession: SessionFactory
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
private var enabled: Set<PortForwardingRule.ID>
/// Enabled IDs created in this process before their rules have been saved.
/// Unlike stale IDs restored from defaults, these survive reconciliation until
/// their editor explicitly discards them or a save makes them runnable.
private var pending: Set<PortForwardingRule.ID> = []
private var desired: Set<PortForwardingRule.ID> = []
private var sessions: [Host.ID: SSHForwardingSession] = [:]
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
private var reconnectTasks: [Host.ID: Task<Void, Never>] = [:]
private var reconnectAttempts: [Host.ID: Int] = [:]
private var connectedAt: [Host.ID: Date] = [:]
private var ruleHostIDs: [PortForwardingRule.ID: Host.ID]
private var stoppingTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
private var stoppingGenerations: [PortForwardingRule.ID: UUID] = [:]
private var connections: [Host.ID: PortForwardingConnection] = [:]
private var presentations: [PortForwardingRule.ID: PortForwardingConnection] = [:]
private var generations: [PortForwardingRule.ID: UUID] = [:]
private var ruleTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
private var savingAuthentication: PortForwardingConnection?
public init(
hosts: HostLibraryModel, defaults: UserDefaults? = nil,
enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs,
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
hosts: HostLibraryModel,
makeSession: @escaping SessionFactory = {
SSHForwardingSession(connectionHandler: $0, failureHandler: $1)
}
) {
self.hosts = hosts
self.keyboardInteractive = keyboardInteractive
self.defaults = defaults
self.enabledDefaultsKey = enabledDefaultsKey
enabled = Set(
defaults?.stringArray(forKey: enabledDefaultsKey)?.compactMap(UUID.init(uuidString:)) ?? [])
ruleHostIDs = Dictionary(uniqueKeysWithValues: hosts.portForwardings.map { ($0.id, $0.hostID) })
self.makeSession = makeSession
}
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState {
states[id] ?? .stopped
}
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { enabled.contains(id) }
public func setEnabled(_ value: Bool, for id: PortForwardingRule.ID) {
if value {
enabled.insert(id)
if let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil {
pending.remove(id)
} else {
pending.insert(id)
}
persistEnabled()
if !pending.contains(id) { activate(id) }
public func start(_ id: PortForwardingRule.ID) {
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
let host = hosts.host(rule.hostID)
else { return }
let context: PortForwardingConnection
if let existing = connections[host.id], !activeIDs(existing).isEmpty {
context = existing
} else {
enabled.remove(id)
pending.remove(id)
persistEnabled()
suspend(id)
context = PortForwardingConnection(host: host, route: hosts.route(for: host))
connections[host.id] = context
}
generations[id] = UUID()
presentations[id] = context
states[id] = .preparing
if context.connected {
startRule(rule, in: context)
} else if context.task == nil && context.authenticationPrompt == nil {
open(context)
} else {
states[id] =
context.authenticationPrompt != nil || context.connection.keyboardAuthentication.current != nil
? .awaitingAuthentication : context.connection.pendingHostKey != nil ? .awaitingTrust : .connecting
}
}
public func setEnabled(_ value: Bool, for ids: [PortForwardingRule.ID]) {
for id in ids { setEnabled(value, for: id) }
}
public func retry(_ id: PortForwardingRule.ID) { start(id) }
/// Restores all, or a changed subset, after launch, foregrounding, or a saved configuration replacement.
public func resumeEnabled(_ ids: [PortForwardingRule.ID]? = nil) {
var valid: Set<PortForwardingRule.ID> = []
for id in enabled {
guard let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil else { continue }
ruleHostIDs[rule.id] = rule.hostID
valid.insert(id)
}
pending.subtract(valid)
let reconciled = valid.union(pending.intersection(enabled))
if reconciled != enabled {
enabled = reconciled
persistEnabled()
}
let targets = ids.map { valid.intersection($0) } ?? valid
targets.forEach(activate)
}
/// Stops live resources while preserving the device-local enabled intent.
public func suspend(_ id: PortForwardingRule.ID) {
let hostID = hosts.portForwarding(id)?.hostID ?? ruleHostIDs[id]
let session = hostID.flatMap { sessions[$0] }
desired.remove(id)
public func stop(_ id: PortForwardingRule.ID) {
generations[id] = nil
states[id] = .stopped
let previous = stoppingTasks[id]
let generation = UUID()
stoppingGenerations[id] = generation
let task = Task {
guard let context = presentations.removeValue(forKey: id) else { return }
let session = context.session
let previous = ruleTasks[id]
ruleTasks[id] = Task {
if let previous { await previous.value }
if let session { await session.stop(id) }
}
stoppingTasks[id] = task
Task { [weak self] in
await task.value
guard let self, self.stoppingGenerations[id] == generation else { return }
self.stoppingTasks[id] = nil
self.stoppingGenerations[id] = nil
if let hostID { await self.disconnectHostIfIdle(hostID) }
}
}
public func suspend(_ ids: [PortForwardingRule.ID]) { ids.forEach(suspend) }
/// Forgets enabled intent only for rules that were enabled before their first save.
public func discardPending(_ ids: [PortForwardingRule.ID]) {
let discarded = pending.intersection(ids)
guard !discarded.isEmpty else { return }
pending.subtract(discarded)
enabled.subtract(discarded)
for id in discarded { states[id] = nil }
persistEnabled()
if activeIDs(context).isEmpty { retire(context) }
}
public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) }
public func stopAll() { stop(Array(presentations.keys)) }
public func remove(_ ids: [PortForwardingRule.ID]) {
for id in ids {
enabled.remove(id)
pending.remove(id)
suspend(id)
ruleHostIDs[id] = nil
}
persistEnabled()
stop(ids)
for id in ids { states[id] = nil }
}
public func suspendAll() {
let all = Array(enabled.union(desired))
desired.removeAll()
for id in all { states[id] = .stopped }
let active = sessions
sessions.removeAll()
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
for task in reconnectTasks.values { task.cancel() }
reconnectTasks.removeAll()
reconnectAttempts.removeAll()
connectedAt.removeAll()
for task in stoppingTasks.values { task.cancel() }
stoppingTasks.removeAll()
stoppingGenerations.removeAll()
Task { for session in active.values { await session.disconnect() } }
}
private func activate(_ id: PortForwardingRule.ID) {
guard enabled.contains(id), !desired.contains(id) else { return }
if let rule = hosts.portForwarding(id) { ruleHostIDs[id] = rule.hostID }
desired.insert(id)
states[id] = .preparing
let stopping = stoppingTasks[id]
Task { [weak self] in
if let stopping { await stopping.value }
guard let self, self.desired.contains(id), self.enabled.contains(id),
let rule = self.hosts.portForwarding(id)
else { return }
await self.start(rule)
/// Connection edits include jump hosts. Authentication's own explicit save may continue its opening.
public func hostConfigurationChanged(_ hostID: Host.ID) {
for context in Array(connections.values) where context.route.contains(where: { $0.id == hostID }) {
guard context !== savingAuthentication else { continue }
stop(activeIDs(context))
}
}
private func persistEnabled() {
defaults?.set(enabled.map(\.uuidString).sorted(), forKey: enabledDefaultsKey)
}
private func start(_ rule: PortForwardingRule) async {
do {
guard rule.isValid else {
throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete."))
}
guard let host = hosts.host(rule.hostID) else {
throw TermindSSHError.route(String(localized: "The forwarding host is unavailable."))
}
let session: SSHForwardingSession
if let existing = sessions[host.id] {
session = existing
} else {
let created = makeSession(for: host.id)
sessions[host.id] = created
session = created
states[rule.id] = .connecting
let task = Task {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await created.connect(
SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive))
}
connectionTasks[host.id] = task
do { try await task.value } catch {
sessions[host.id] = nil
connectionTasks[host.id] = nil
throw error
}
connectionTasks[host.id] = nil
connectedAt[host.id] = Date()
}
if let task = connectionTasks[host.id] { try await task.value }
guard desired.contains(rule.id) else { return }
states[rule.id] = .starting
try await session.start(rule)
guard desired.contains(rule.id) else {
await session.stop(rule.id)
return
}
states[rule.id] = .running(activeConnections: 0)
} catch {
guard desired.contains(rule.id) else {
states[rule.id] = .stopped
await disconnectHostIfIdle(rule.hostID)
return
}
if let sshError = error as? TermindSSHError,
sshError.isRetryableForwardingFailure
{
if let session = sessions.removeValue(forKey: rule.hostID) {
await session.disconnect()
}
if reconnectTasks[rule.hostID] == nil {
scheduleReconnect(
rule.hostID, attempt: 1, message: error.localizedDescription
)
}
} else {
desired.remove(rule.id)
states[rule.id] = .failed(error.localizedDescription)
await disconnectHostIfIdle(rule.hostID)
public func reconcileConfiguration() {
for context in Array(connections.values) {
guard
context.route.allSatisfy({ old in
hosts.host(old.id).map { old.hasSameConnectionRoute(as: $0) } == true
})
else {
stop(activeIDs(context))
continue
}
}
remove(presentations.keys.filter { hosts.portForwarding($0) == nil })
}
private func disconnectHostIfIdle(_ hostID: Host.ID) async {
let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID }
guard !hasActiveRule else { return }
let session = sessions.removeValue(forKey: hostID)
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
reconnectTasks[hostID]?.cancel()
reconnectTasks[hostID] = nil
reconnectAttempts[hostID] = nil
connectedAt[hostID] = nil
if let session { await session.disconnect() }
public func submitAuthentication(
for id: PortForwardingRule.ID, promptID: UUID,
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
) {
guard generations[id] != nil, let context = presentations[id],
let prompt = context.authenticationPrompt, prompt.id == promptID,
var host = hosts.host(prompt.host.id)
else { return }
let identity = identityID.flatMap(hosts.identity)
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if prompt.requiresPassphrase, var staged = context.submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
context.submissions[host.id] = staged
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
savingAuthentication = context
defer { savingAuthentication = nil }
do {
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = saved.id
guard hosts.saveHost(host) else {
throw TermindSSHError.library(hosts.storageError ?? String(localized: "Unable to Save"))
}
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
context.setPrompt(failed)
return
}
}
context.submissions[host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase, keyID: chosenKey, identityID: identity?.id)
}
context.setPrompt(nil)
context.journey.resume()
open(context)
}
private func makeSession(for hostID: Host.ID) -> SSHForwardingSession {
SSHForwardingSession(
connectionHandler: { [weak self] id, count in
public func acceptHostKey(for id: UUID, requestID: UUID, observation: SSHHostKeyObservation, remember: Bool) {
guard generations[id] != nil, let context = presentations[id],
context.hostKeyRequestID == requestID,
context.connection.pendingHostKey?.observation == observation
else { return }
if remember {
context.connection.trustHostKey(observation, using: hosts)
} else {
_ = context.connection.useTemporaryHostKey(observation)
}
update(context, state: .connecting)
}
private func activeIDs(_ context: PortForwardingConnection) -> [UUID] {
generations.keys.filter { presentations[$0] === context }
}
private func isCurrent(_ context: PortForwardingConnection, attempt: UUID) -> Bool {
connections[context.hostID] === context && context.attemptID == attempt && !activeIDs(context).isEmpty
}
private func update(_ context: PortForwardingConnection, state: PortForwardingRuntimeState) {
for id in activeIDs(context) { states[id] = state }
}
private func open(_ context: PortForwardingConnection) {
guard let host = hosts.host(context.hostID), !activeIDs(context).isEmpty else { return }
let attempt = UUID()
context.attemptID = attempt
context.journey.begin(
host: host, route: hosts.route(for: host), purpose: .portForwarding,
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
update(context, state: .preparing)
let session = makeSession(
{ [weak self, weak context] id, count in
Task { @MainActor in
guard let self, self.desired.contains(id) else { return }
guard let self, let context, self.isCurrent(context, attempt: attempt),
self.generations[id] != nil, self.presentations[id] === context,
case .running = self.states[id]
else { return }
self.states[id] = .running(activeConnections: count)
}
},
failureHandler: { [weak self] message in
Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) }
}
)
}
private func handleConnectionFailure(_ hostID: Host.ID, message: String) {
guard reconnectTasks[hostID] == nil else { return }
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty else { return }
sessions[hostID] = nil
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true
let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1
scheduleReconnect(hostID, attempt: attempt, message: message)
}
private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) {
let delays: [TimeInterval] = [1, 2, 5, 10, 30]
let delay = delays[min(attempt - 1, delays.count - 1)]
let nextRetry = Date().addingTimeInterval(delay)
reconnectAttempts[hostID] = attempt
for id in desiredRuleIDs(for: hostID) {
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
}
reconnectTasks[hostID] = Task { [weak self] in
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
guard let self else { return }
await self.reconnect(hostID, attempt: attempt)
}
}
private func reconnect(_ hostID: Host.ID, attempt: Int) async {
reconnectTasks[hostID] = nil
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty, let host = hosts.host(hostID) else { return }
let session = makeSession(for: hostID)
do {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await session.connect(configuration)
guard !desiredRuleIDs(for: hostID).isEmpty else {
await session.disconnect()
return
}
sessions[hostID] = session
connectedAt[hostID] = Date()
for id in desiredRuleIDs(for: hostID) {
guard let rule = hosts.portForwarding(id) else { continue }
states[id] = .starting
try await session.start(rule)
states[id] = .running(activeConnections: 0)
}
} catch {
sessions[hostID] = nil
await session.disconnect()
if let sshError = error as? TermindSSHError,
!sshError.isRetryableForwardingFailure
{
for id in desiredRuleIDs(for: hostID) {
desired.remove(id)
states[id] = .failed(error.localizedDescription)
{ [weak self, weak context] message in
Task { @MainActor in
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
self.fail(context, message: message)
}
})
context.session = session
context.task = Task { [weak self, weak context] in
guard let self, let context else { return }
do {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: context.submissions,
temporaryHostKeys: context.connection.temporaryHostKeys)
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
update(context, state: .connecting)
try await session.connect(
SSHConnectionRequest(
configuration: configuration,
authorizeHostKey: { [weak self, weak context] challenge in
guard let self, let context else { throw CancellationError() }
return await self.authorize(challenge, in: context, attempt: attempt)
},
keyboardInteractive: { [weak self, weak context] challenge in
guard let self, let context else { throw CancellationError() }
return try await self.answer(challenge, in: context, attempt: attempt)
}),
progress: { [weak self, weak context] event in
Task { @MainActor in
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
context.journey.receive(event)
}
})
try Task.checkCancellation()
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
context.task = nil
context.connected = true
context.submissions.removeAll()
context.connection.temporaryHostKeys.removeAll()
for id in activeIDs(context) {
if let rule = hosts.portForwarding(id) { startRule(rule, in: context) }
}
} catch {
await session.disconnect()
guard isCurrent(context, attempt: attempt) else { return }
context.task = nil
context.attemptID = nil
context.session = nil
if let ssh = error as? TermindSSHError,
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
{
requestAuthentication(context, error: ssh)
} else {
fail(context, message: error.localizedDescription)
}
reconnectAttempts[hostID] = nil
} else {
scheduleReconnect(
hostID, attempt: attempt + 1, message: error.localizedDescription
)
}
}
}
private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] {
desired.filter { hosts.portForwarding($0)?.hostID == hostID }
private func authorize(
_ challenge: SSHHostKeyChallenge, in context: PortForwardingConnection, attempt: UUID
) async -> SSHHostKeyDecision {
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
let requestID = UUID()
context.hostKeyRequestID = requestID
defer {
if context.hostKeyRequestID == requestID { context.hostKeyRequestID = nil }
}
update(context, state: .awaitingTrust)
let decision = await context.connection.authorizeHostKey(challenge, using: hosts)
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
update(context, state: .connecting)
return decision
}
private func answer(
_ challenge: SSHKeyboardInteractiveChallenge, in context: PortForwardingConnection, attempt: UUID
) async throws -> [String] {
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
try Task.checkCancellation()
update(context, state: .awaitingAuthentication)
let answers = try await context.connection.answerKeyboardInteractive(challenge)
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
update(context, state: .connecting)
return answers
}
private func requestAuthentication(_ context: PortForwardingConnection, error: TermindSSHError) {
guard let host = hosts.host(error.routeHostID ?? context.hostID) else {
fail(context, message: error.localizedDescription)
return
}
let staged = context.submissions[host.id]
let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase = keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true && staged?.passphrase == nil
context.setPrompt(
SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity))
if error.isServerAuthenticationChallenge {
context.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else if passphrase {
context.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
} else {
context.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
}
update(context, state: .awaitingAuthentication)
}
private func startRule(_ rule: PortForwardingRule, in context: PortForwardingConnection) {
guard let token = generations[rule.id], let session = context.session else { return }
states[rule.id] = .starting
let previous = ruleTasks[rule.id]
ruleTasks[rule.id] = Task { [weak self] in
if let previous { await previous.value }
guard let self, generations[rule.id] == token else { return }
do {
try await session.start(rule)
guard generations[rule.id] == token else { return }
states[rule.id] = .running(activeConnections: 0)
context.journey.succeed()
} catch {
guard generations[rule.id] == token else { return }
generations[rule.id] = nil
states[rule.id] = .failed(error.localizedDescription)
if activeIDs(context).isEmpty {
retire(context)
context.journey.fail(error.localizedDescription)
}
}
}
}
private func fail(_ context: PortForwardingConnection, message: String) {
for id in activeIDs(context) {
generations[id] = nil
states[id] = .failed(message)
}
retire(context)
context.journey.fail(message)
}
private func retire(_ context: PortForwardingConnection) {
context.attemptID = nil
context.task?.cancel()
context.task = nil
context.connected = false
context.setPrompt(nil)
context.hostKeyRequestID = nil
context.submissions.removeAll()
context.connection.disconnect()
let session = context.session
context.session = nil
if let session { Task { await session.disconnect() } }
if connections[context.hostID] === context { connections[context.hostID] = nil }
}
}
@@ -4,7 +4,14 @@ import Foundation
import TermindApplication
import TermindDomain
public actor SSHForwardingSession {
public protocol SSHForwardingSessionProtocol: Actor {
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
func start(_ rule: PortForwardingRule) async throws
func stop(_ id: PortForwardingRule.ID) async
func disconnect() async
}
public actor SSHForwardingSession: SSHForwardingSessionProtocol {
public typealias ConnectionHandler = @Sendable (PortForwardingRule.ID, Int) -> Void
public typealias FailureHandler = @Sendable (String) -> Void
@@ -23,8 +30,8 @@ public actor SSHForwardingSession {
try await connect(SSHConnectionRequest(configuration: configuration))
}
public func connect(_ request: SSHConnectionRequest) async throws {
try await worker.connect(request)
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler? = nil) async throws {
try await worker.connect(request, progress: progress)
}
public func start(_ rule: PortForwardingRule) async throws {
@@ -93,9 +100,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
self.failureHandler = failureHandler
}
public func connect(_ request: SSHConnectionRequest) async throws {
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws {
await queue.perform { self.cleanup() }
let opened = try await SSHSessionWorker.openAuthenticatedSession(using: request, timeout: 20)
let opened = try await SSHSessionWorker.openAuthenticatedSession(
using: request, timeout: 20, progress: progress)
do {
await queue.perform {
self.routeTransport = opened.transport
+35 -9
View File
@@ -21,7 +21,7 @@ The agent runtime is the separately versioned `AgentKit` package.
- `TermindPresentation` owns reusable SwiftUI content, presentation policy,
Ghostty themes, and the `TerminalPreferences` bridge. It depends only on
Application and Domain; native surfaces, transports, and lifetimes stay in apps.
- `TermindPersistence` exclusively owns every SwiftData model, frozen V1-V18
- `TermindPersistence` exclusively owns every SwiftData model, frozen V1-V19
schema, migration plan, and container factory. It never depends on an app target.
- `TFM` and `TFI` own platform UI, adapters, and lifecycle orchestration. Both use
`App`, `Application`, `Design`, `DesignSystem`, `Features`, `Infrastructure`,
@@ -104,7 +104,7 @@ cleanup is performed.
closing another view of that conversation cannot cancel the recording.
- Desktop navigation starts with Vault and Assistant, places Metrics above Vault
libraries, appends dynamic SSH/SFTP Workspaces, and ends with Settings. Vault
owns Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor routes.
owns Hosts, Keychain, Port Forwarding, Snippets, Known Hosts, Welcome, and their editor routes.
- Desktop main pages share a 360-point detail minimum, independent of the
Assistant's 840-point reading maximum. Opening the Navigator grows the window
when necessary. Vault overlays its existing editor below 721 points and keeps
@@ -132,8 +132,8 @@ cleanup is performed.
share one Vault snapshot. `VaultStore` owns the transaction; sessions use
`RemoteSessionHostStore`, never the full mutation API.
- Runtime connection state is never written onto domain records. Saved repository
data is persistent truth; device-local enabled forwarding intent is UserDefaults
state and is excluded from Vault/Metrics/Settings sync.
data is persistent truth; forwarding runs and authentication exist only in memory
and never enter Vault/Metrics/Settings sync or UserDefaults.
- Serial sessions are temporary and local. IOKit/termios objects remain in
Infrastructure; serial paths and parameters are neither persisted nor exposed
as Agent hosts.
@@ -187,17 +187,43 @@ callback, cancels its responder, and interrupts the pending transport.
User connections own their challenge queues. Agent runs reuse their application
interaction queue; macOS background operations and MCP share the native authentication
window with independently cancellable origins. Stopping MCP preserves app-owned
requests. Mobile background operations use an app-owned queue presented by one active
scene. Terminal file drops and all additional transfer connections forbid Termind
requests. Terminal file drops and all additional transfer connections forbid Termind
credential, passphrase, MFA and host-key forms, including fallback responders on jump
hosts. System key authorization such as Touch ID remains allowed. Username-only and
SSH `none` authentication remain eligible; actual connection success decides availability.
No upload-specific mobile sheet exists; the app-owned MFA sheet serves forwarding,
while Metrics owns its challenges inside Connection Journey on both platforms.
No app-owned mobile authentication sheet exists. Forwarding and Metrics each own
their challenges inside Connection Journey on both platforms; forwarding never uses
the desktop external authentication window.
Cancellation interrupts opening, and temporary-file cleanup must also
remain silent. Cleanup requiring new interaction retains the existing failure report.
Port Forwarding is an independent Vault card library between Keychain and Snippets
on both platforms. Its cards share Hosts' two-row height and show the same name,
address, type, Host and runtime state. An unnamed rule uses the Host name and SSH
argument, such as `AWS (-L)`, including its detail title. Desktop Journey lives in
the Vault detail column, and double-clicking a desktop card starts/stops it without
an inline action button. Desktop cards use Vault's window-owned multiple and marquee
selection; the multi-selection context menu offers Stop (disabled without active rules)
and transactional Delete. Starting and retrying always target one rule; neither platform
offers bulk start or Start All/Stop All controls. Mobile pushes a detail page with Start/Stop/Retry in the
trailing toolbar. Host editors never own forwarding drafts; each rule is saved or
deleted in its own rollback-safe Vault transaction and retains its Host reference.
The shared color picker edits a rule's persisted palette value and both card icons
use that color. V20 adds the color with a lightweight migration while freezing the
V1–V19 forwarding entity; legacy JSON defaults to no color. Color travels in the
existing Vault rule payload, with no change to record identity or Host references.
Rules start only through explicit user actions. Startup, foregrounding, saved/synced
configuration changes and failures never open or retry a forwarding connection; the
legacy enabled-rule defaults are unused. Background expiration and app termination
stop all forwarding resources. Navigation does not own transport or prompt lifetimes.
Forwarding rules sharing a host session share authentication; a new connection must
answer fresh challenges. An Identity may contain only a username: preparation
answer fresh challenges. Stopping one rule preserves its peers; retiring the last
rule cancels the opening and prompts. Listener failures affect only that rule;
transport failure fails all current rules without restarting them. Host route edits
(including jump hosts) and rule replacement stop affected runs. Authentication's own
Continue and Save may continue its explicitly requested opening. Connection and rule
generations reject late callbacks, and stop/start serializes listener cleanup.
An Identity may contain only a username: preparation
permits an empty credential list, SSH tries `none`, then advertised methods. Missing
configured secrets and existing authentication failure/retry behavior stay unchanged.
-4
View File
@@ -7,7 +7,6 @@ struct MobileAppDependencies {
var hosts: (any HostRepository)?
var inMemory: Bool
var defaults: UserDefaults?
var forwardingDefaults: UserDefaults?
var background: any MobileBackgroundTaskManaging
var workspaceConnections: MobileWorkspaceConnectionServices
var metrics: MobileMetricsServices
@@ -21,7 +20,6 @@ struct MobileAppDependencies {
hosts: nil,
inMemory: false,
defaults: .standard,
forwardingDefaults: .standard,
background: MobileBackgroundController(),
workspaceConnections: .live,
metrics: .live,
@@ -32,7 +30,6 @@ struct MobileAppDependencies {
static func testing(
hosts: (any HostRepository)? = nil,
defaults: UserDefaults? = nil,
forwardingDefaults: UserDefaults? = nil,
background: any MobileBackgroundTaskManaging = MobileBackgroundController(),
workspaceConnections: MobileWorkspaceConnectionServices = .live,
metrics: MobileMetricsServices = .testing,
@@ -42,7 +39,6 @@ struct MobileAppDependencies {
hosts: hosts,
inMemory: true,
defaults: defaults,
forwardingDefaults: forwardingDefaults,
background: background,
workspaceConnections: workspaceConnections,
metrics: metrics,
+14 -26
View File
@@ -66,11 +66,6 @@ final class MobileAppModel {
@ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = []
private let defaults: UserDefaults?
private let snippetTimestampFallback: Date
let keyboardAuthentication: SSHKeyboardInteractiveQueue
var keyboardAuthenticationSceneID: UUID? {
scenePhases.filter { $0.value == .active }.keys.sorted { $0.uuidString < $1.uuidString }.first
}
private let background: any MobileBackgroundTaskManaging
private let workspaceConnectionServices: MobileWorkspaceConnectionServices
private let workspaceStore: MobileWorkspaceStore
@@ -88,7 +83,6 @@ final class MobileAppModel {
hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard,
background: any MobileBackgroundTaskManaging = MobileBackgroundController(),
workspaceConnectionServices: MobileWorkspaceConnectionServices = .live,
forwardingDefaults: UserDefaults? = nil,
appReview: AppReviewController = AppReviewController()
) throws {
try self.init(
@@ -96,7 +90,6 @@ final class MobileAppModel {
hosts: hosts,
inMemory: inMemory,
defaults: defaults,
forwardingDefaults: forwardingDefaults ?? (inMemory || hosts != nil ? nil : defaults),
background: background,
workspaceConnections: workspaceConnectionServices,
metrics: inMemory || hosts != nil ? .testing : .live,
@@ -109,10 +102,7 @@ final class MobileAppModel {
let inMemory = dependencies.inMemory
let defaults = dependencies.defaults
let background = dependencies.background
let keyboardAuthentication = SSHKeyboardInteractiveQueue()
self.keyboardAuthentication = keyboardAuthentication
let workspaceConnectionServices = dependencies.workspaceConnections.withKeyboardInteractive(
keyboardAuthentication.handler)
let workspaceConnectionServices = dependencies.workspaceConnections
self.background = background
self.workspaceConnectionServices = workspaceConnectionServices
workspaceStore =
@@ -197,15 +187,16 @@ final class MobileAppModel {
snippets = SnippetLibraryModel(library: library)
knownHosts = KnownHostsLibraryModel(library: library)
forwardingLibrary = ForwardingLibraryModel(library: library)
forwardings = PortForwardingController(
hosts: library, defaults: dependencies.forwardingDefaults,
keyboardInteractive: keyboardAuthentication.handler)
forwardings = PortForwardingController(hosts: library)
transfers = SFTPTransferCoordinator(
store: library, clientFactory: { SFTPClient() })
sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) })
library.willDeleteHost = { [weak sftpEdits] hostID, _ in sftpEdits?.invalidate(hostID: hostID) }
library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.suspend(ids) }
library.didReplacePortForwardings = { [weak forwardings] ids in forwardings?.resumeEnabled(ids) }
library.willDeleteHost = { [weak sftpEdits, weak forwardings] hostID, _ in
sftpEdits?.invalidate(hostID: hostID)
forwardings?.hostConfigurationChanged(hostID)
}
library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.stop(ids) }
library.didChangeHostConnection = { [weak forwardings] in forwardings?.hostConfigurationChanged($0) }
let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id))
for host in library.hosts where !visibleHostIDs.contains(host.id) { library.projectHostDeletion(host.id) }
outbox.registerHostDeletionHandlers(
@@ -221,9 +212,9 @@ final class MobileAppModel {
guard let self else { return }
sftpEdits.invalidate(hostID: payload.hostID)
transfers.cancelAll(for: payload.hostID)
keyboardAuthentication.cancelAll(hostID: payload.hostID)
},
stopForwardings: { [weak forwardings] payload in
forwardings?.hostConfigurationChanged(payload.hostID)
forwardings?.remove(payload.forwardingRuleIDs)
}
)
@@ -322,7 +313,7 @@ final class MobileAppModel {
closeWorkspaces(for: id)
sftpEdits.invalidate(hostID: id)
transfers.cancelAll(for: id)
keyboardAuthentication.cancelAll(hostID: id)
forwardings.hostConfigurationChanged(id)
forwardings.remove(library.portForwardings(for: id).map(\.id))
metrics.remove(id)
try hosts.requestHostDeletion(id)
@@ -356,9 +347,8 @@ final class MobileAppModel {
background.begin { [weak self] in
guard let self else { return }
for workspace in workspaces { workspace.suspendForBackgroundExpiration() }
forwardings.suspendAll()
forwardings.stopAll()
transfers.pauseAll()
keyboardAuthentication.cancelAll()
assistant.expireBackground()
}
liveActivities.update(workspaceActivitySessions)
@@ -368,7 +358,6 @@ final class MobileAppModel {
Task { [weak self] in await self?.pro.refresh() }
background.finish()
for workspace in workspaces { workspace.resumeAfterBackground() }
forwardings.resumeEnabled()
reconcileSync()
outbox.start()
liveActivities.update(workspaceActivitySessions)
@@ -408,8 +397,7 @@ final class MobileAppModel {
for workspace in workspaces.filter({ $0.descriptor.hostID == change.id }) { workspace.disconnect() }
}
transfers.cancelAll(for: change.id)
keyboardAuthentication.cancelAll(hostID: change.id)
forwardings.suspend(library.portForwardings(for: change.id).map(\.id))
forwardings.stop(library.portForwardings(for: change.id).map(\.id))
}
if change.isRemoved {
closeWorkspaces(for: change.id)
@@ -418,9 +406,9 @@ final class MobileAppModel {
metrics.remove(change.id)
}
}
forwardings.suspend(plan.forwardingRuleIDs)
forwardings.stop(plan.forwardingRuleIDs)
library.replacePersistedSnapshot(snapshot)
forwardings.resumeEnabled()
forwardings.reconcileConfiguration()
liveActivities.update(workspaceActivitySessions)
}
-1
View File
@@ -26,7 +26,6 @@ struct MobileAppReviewPresenter: ViewModifier {
|| scene.renamingWorkspace != nil
|| scene.isWorkspaceUnavailable
|| app.error != nil
|| app.keyboardAuthentication.current != nil
|| app.pro.isPaywallPresented
|| app.pro.isProcessing,
request: { requestReview() }
-17
View File
@@ -40,23 +40,6 @@ struct MobileRootView: View {
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
MobileProView(pro: app.pro)
}
.sheet(
isPresented: Binding(
get: { app.keyboardAuthentication.current != nil && app.keyboardAuthenticationSceneID == scene.id },
set: { _ in }
)
) {
if let challenge = app.keyboardAuthentication.current {
SSHKeyboardInteractiveForm(
challenge: challenge,
submit: { app.keyboardAuthentication.respond(challenge.id, answers: $0) },
cancel: { app.keyboardAuthentication.cancel(challenge.id) }
)
.id(challenge.id)
.padding(24)
.interactiveDismissDisabled()
}
}
.sheet(item: $scene.renamingWorkspace) { workspace in
MobileWorkspaceRenameView(workspace: workspace) { title in
app.renameWorkspace(workspace.id, to: title)
@@ -24,16 +24,4 @@ struct MobileWorkspaceConnectionServices {
) async throws -> any SFTPClientProtocol
var writeSSH: (SSHSessionActor, Data) async -> Void
func withKeyboardInteractive(_ handler: @escaping SSHKeyboardInteractiveHandler) -> Self {
var services = self
services.connectSSH = { session, request, integration, columns, rows, progress in
try await self.connectSSH(
session, request.withKeyboardInteractiveFallback(handler), integration, columns, rows, progress
)
}
services.connectSFTP = { request, progress, failure in
try await self.connectSFTP(request.withKeyboardInteractiveFallback(handler), progress, failure)
}
return services
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import CoreGraphics
/// Mobile layout values shared by managed collection surfaces.
nonisolated enum MobileCardLayout {
static let minimumHeight: CGFloat = 104
static let minimumHeight: CGFloat = 106
static let maximumVisibleTags = 2
static func columns(width: CGFloat, accessibilitySize: Bool) -> Int {
+4 -88
View File
@@ -10,27 +10,20 @@ struct MobileHostEditor: View {
private var library: HostLibraryModel { app.library }
let original: Host
@State private var draft: Host
private let originalForwardings: [PortForwardingRule]
@State private var draftForwardings: [PortForwardingRule]
@State private var editingForwarding: PortForwardingRule?
init(app: MobileAppModel, host: Host) {
self.app = app
let library = app.library
original = host
_draft = State(initialValue: host)
originalForwardings = library.portForwardings(for: host.id)
_draftForwardings = State(initialValue: library.portForwardings(for: host.id))
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Host"), isDirty: draft != original || draftForwardings != originalForwardings,
canSave: MobileHostEditorPolicy.canSave(draft, forwardings: draftForwardings),
onCancel: cancel,
title: String(localized: "Host"), isDirty: draft != original,
canSave: MobileHostEditorPolicy.canSave(draft),
save: {
if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host }
_ = library.upsert(draft, replacingPortForwardings: draftForwardings)
library.upsert(draft)
if let error = library.storageError { throw MobileEditorError.message(error) }
},
content: {
@@ -89,58 +82,6 @@ struct MobileHostEditor: View {
}
}
}
Section("Port Forwarding") {
ForEach(draftForwardings) { rule in
let runtime = app.forwardings.state(for: rule.id)
let state = forwardingState(for: rule, runtimeIsActive: runtime.isActive)
let isRunning = state.isEnabled || state.isRuntimeActive
HStack {
Button {
editingForwarding = rule
} label: {
Label {
VStack(alignment: .leading) {
Text(rule.label.isEmpty ? rule.bindAddress : rule.label)
Text(
state.isWaitingForSave
? String(localized: "Waiting for Save") : runtime.label
)
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.left.arrow.right").accessibilityHidden(true)
}
}
Spacer()
Button(
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
systemImage: isRunning ? "stop" : "play"
) {
app.forwardings.setEnabled(!isRunning, for: rule.id)
}.disabled(!state.canToggle)
}
.contextMenu {
Button("Duplicate", systemImage: "plus.square.on.square") {
var copy = rule
copy.id = UUID()
draftForwardings.append(copy)
}
.disabled(!state.canEdit)
Button("Delete", systemImage: "trash", role: .destructive) {
app.forwardings.discardPending([rule.id])
draftForwardings.removeAll { $0.id == rule.id }
}
.disabled(!state.canEdit)
}
}
Button("Add Forwarding", systemImage: "plus") {
editingForwarding = PortForwardingRule(hostID: draft.id)
}
Text(
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
)
.font(.caption).foregroundStyle(.secondary)
}
Section("Color") {
ColorTagPicker(selection: $draft.colorTag, layout: .touch)
}
@@ -152,22 +93,6 @@ struct MobileHostEditor: View {
}
}
)
.sheet(item: $editingForwarding) { rule in
MobileForwardingEditor(
app: app, host: draft, rule: rule,
isNew: !draftForwardings.contains { $0.id == rule.id }
) { updated in
if let index = draftForwardings.firstIndex(where: { $0.id == updated.id }) {
draftForwardings[index] = updated
} else {
draftForwardings.append(updated)
}
}
}
}
private func cancel() {
app.forwardings.discardPending(draftForwardings.map(\.id))
}
private var proxyKind: Binding<HostProxy.ProtocolKind?> {
@@ -205,22 +130,13 @@ struct MobileHostEditor: View {
library.identities.filter(library.isProxyEligibleOnThisDevice)
}
private func forwardingState(
for rule: PortForwardingRule, runtimeIsActive: Bool
) -> PortForwardingEditorPolicy {
PortForwardingEditorPolicy(
rule: rule,
savedRule: app.forwardingLibrary.portForwarding(rule.id), savedHost: library.host(draft.id),
isEnabled: app.forwardings.isEnabled(rule.id), isRuntimeActive: runtimeIsActive)
}
}
nonisolated enum MobileHostEditorPolicy {
static func canSave(_ host: Host, forwardings: [PortForwardingRule]) -> Bool {
static func canSave(_ host: Host) -> Bool {
!host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(host.port)
&& (host.proxy?.isValid ?? true)
&& forwardings.allSatisfy(\.isValid)
}
static func selectProxy(
+15 -52
View File
@@ -2,75 +2,38 @@ import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
/// Rules belong to the host draft and commit in its one Vault transaction.
struct MobileForwardingEditor: View {
let app: MobileAppModel
let host: Host
let original: PortForwardingRule
let isNew: Bool
let save: (PortForwardingRule) -> Void
@State private var draft: PortForwardingRule
init(
app: MobileAppModel, host: Host, rule: PortForwardingRule, isNew: Bool,
save: @escaping (PortForwardingRule) -> Void
) {
init(app: MobileAppModel, rule: PortForwardingRule, isNew: Bool) {
self.app = app
self.host = host
original = rule
self.isNew = isNew
self.save = save
_draft = State(initialValue: rule)
}
private var canSave: Bool {
PortForwardingEditorPolicy.normalized(draft) != nil && app.library.host(draft.hostID) != nil
&& !app.forwardings.state(for: draft.id).isActive
&& (isNew || app.forwardingLibrary.portForwarding(draft.id) != nil)
}
var body: some View {
MobileEditorSheet(
title: isNew ? String(localized: "New Forwarding") : String(localized: "Edit Forwarding"),
isDirty: draft != original || (isNew && state.isEnabled),
canSave: draft.isValid && state.canEdit,
onCancel: cancel,
save: { save(draft) },
isDirty: draft != original, canSave: canSave,
save: {
guard canSave, let rule = PortForwardingEditorPolicy.normalized(draft) else { return }
try app.forwardingLibrary.save(rule)
},
content: {
Section {
Toggle("Enabled", isOn: enabled)
.disabled(!state.canToggle)
LabeledContent(
"Status",
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : runtime.label
)
if state.isWaitingForSave {
Text("This forwarding will start after the host is saved.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
PortForwardingFields(rule: $draft)
.disabled(!state.canEdit)
PortForwardingHostPicker(rule: $draft, hosts: app.forwardingLibrary.hosts)
.disabled(app.forwardings.state(for: draft.id).isActive)
PortForwardingFields(rule: $draft).disabled(app.forwardings.state(for: draft.id).isActive)
})
}
private var runtime: PortForwardingRuntimeState { app.forwardings.state(for: draft.id) }
private var state: PortForwardingEditorPolicy {
PortForwardingEditorPolicy(
rule: draft,
savedRule: app.forwardingLibrary.portForwarding(draft.id), savedHost: app.library.host(host.id),
isEnabled: app.forwardings.isEnabled(draft.id), isRuntimeActive: runtime.isActive)
}
private var enabled: Binding<Bool> {
Binding(
get: { state.isEnabled || state.isRuntimeActive },
set: { value in
app.forwardings.setEnabled(value, for: draft.id)
})
}
private func cancel() {
if isNew { app.forwardings.discardPending([draft.id]) }
}
}
@@ -0,0 +1,227 @@
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("Edit", systemImage: "pencil") { editing = rule }.disabled(runtime.isActive)
Button("Duplicate", systemImage: "plus.square.on.square") { duplicate(rule) }
Button("Copy Address", systemImage: "doc.on.doc") { UIPasteboard.general.string = rule.bindAddress }
Button("Copy SSH Command") { UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule) }
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { removing = rule }
}
}
private func canStart(_ rule: PortForwardingRule) -> Bool {
PortForwardingEditorPolicy(
rule: nil, savedRule: rule, savedHost: app.library.host(rule.hostID),
isRuntimeActive: app.forwardings.state(for: rule.id).isActive
).canStart
}
private func start(_ rule: PortForwardingRule) {
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 {
try app.forwardingLibrary.save(copy)
editing = 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.label, 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(app.library.host(rule.hostID) == nil || !rule.isValid)
}
}
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()
}
}
+31 -1
View File
@@ -67,6 +67,7 @@ private struct MobileVaultLinkDivider: View {
struct MobileVaultView: View {
private enum SearchDestination {
case keychain(useQuery: Bool)
case portForwarding(useQuery: Bool)
case snippets(useQuery: Bool)
case knownHosts(useQuery: Bool)
case metrics
@@ -148,6 +149,22 @@ struct MobileVaultView: View {
return result
}
private var forwardingSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if pageTitle("Port Forwarding").localizedCaseInsensitiveContains(query) {
result.append(
SearchLink(
id: "forwarding-page", title: pageTitle("Port Forwarding"), detail: nil,
symbol: "arrow.left.arrow.right", destination: .portForwarding(useQuery: false)))
}
result += app.forwardingLibrary.search(query).map { rule in
SearchLink(
id: "forwarding-\(rule.id)", title: rule.displayName(hostName: app.library.host(rule.hostID)?.name),
detail: rule.summary, symbol: rule.kind.symbol, destination: .portForwarding(useQuery: true))
}
return result
}
private var snippetSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if matchesSnippetsPage {
@@ -199,7 +216,7 @@ struct MobileVaultView: View {
private var hasSearchResults: Bool {
!matchingHosts.isEmpty || !keychainSearchLinks.isEmpty || !snippetSearchLinks.isEmpty
|| !knownHostSearchLinks.isEmpty || !metricSearchLinks.isEmpty
|| !forwardingSearchLinks.isEmpty || !knownHostSearchLinks.isEmpty || !metricSearchLinks.isEmpty
}
var body: some View {
@@ -282,6 +299,11 @@ struct MobileVaultView: View {
}
}
}
if !forwardingSearchLinks.isEmpty {
MobileCardSection(title: "Port Forwarding") {
MobileVaultLinkGroup { searchLinkRows(forwardingSearchLinks) }
}
}
if !snippetSearchLinks.isEmpty {
MobileCardSection(title: "Snippets") {
MobileVaultLinkGroup {
@@ -313,6 +335,11 @@ struct MobileVaultView: View {
.mobileReviewPage("vault.keychain", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("Port Forwarding", symbol: "arrow.left.arrow.right") {
MobileForwardingView(app: app, reviewScene: reviewScene)
.mobileReviewPage("vault.portForwarding", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("Snippets", symbol: "curlybraces") {
MobileSnippetsView(app: app, execute: executeSnippet)
.mobileReviewPage("vault.snippets", scene: reviewScene)
@@ -369,6 +396,9 @@ struct MobileVaultView: View {
case .keychain(let useQuery):
MobileCredentialsView(app: app, initialQuery: useQuery ? query : "")
.mobileReviewPage("vault.keychain", scene: reviewScene)
case .portForwarding(let useQuery):
MobileForwardingView(app: app, initialQuery: useQuery ? query : "", reviewScene: reviewScene)
.mobileReviewPage("vault.portForwarding", scene: reviewScene)
case .snippets(let useQuery):
MobileSnippetsView(
app: app, initialQuery: useQuery ? query : "", execute: executeSnippet
+28
View File
@@ -45,6 +45,34 @@ struct MobileCardTests {
#expect(Array(MobileCardLayout.visibleTags(["one", "two", "three"])) == ["one", "two"])
}
@Test func forwardingCardsMatchHostHeightWithOrWithoutNames() {
for width: CGFloat in [320, 398, 600] {
let hostHeight = measuredHeight(of: standardActionCard(tags: ["production"]), width: width)
for kind in PortForwardingRule.Kind.allCases {
for name in ["", "Database with a long display name that stays on one line"] {
let rule = PortForwardingRule(
hostID: UUID(), kind: kind, targetHost: "database.production.example.invalid", label: name)
let card = MobileActionCard(
action: {},
content: { MobileForwardingCardContent(rule: rule, hostName: "Production", runtime: .stopped) },
actions: { EmptyView() })
#expect(abs(measuredHeight(of: card, width: width) - hostHeight) <= 1)
}
}
}
}
@Test func unnamedForwardingUsesTheHostAndSSHArgument() {
for (kind, argument) in [(PortForwardingRule.Kind.local, "-L"), (.remote, "-R"), (.dynamic, "-D")] {
var rule = PortForwardingRule(hostID: UUID(), kind: kind)
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
rule.label = "Database"
#expect(rule.displayName(hostName: "AWS") == "Database")
rule.label = " "
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
}
}
@Test func hostSectionsFoldNestedGroupsKeepEmptyGroupsAndLeaveSearchAvailable() throws {
let app = try MobileAppModel(inMemory: true)
let root = HostGroup(name: "Production")
@@ -426,7 +426,6 @@ struct MobileUploadBatchTests {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let queue = SSHKeyboardInteractiveQueue()
let client = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
@@ -443,7 +442,7 @@ struct MobileUploadBatchTests {
prompts: [.init(text: "Code", echo: false)]))
return client
}, writeSSH: { _, _ in }
).withKeyboardInteractive(queue.handler)
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, transfers: transfers, connectionServices: services)
@@ -454,7 +453,7 @@ struct MobileUploadBatchTests {
#expect(workspace.uploadTargetProblem == nil)
#expect(workspace.prepareUpload([URL(fileURLWithPath: "/tmp/termind-test-upload")]))
#expect(await eventually { workspace.operationError != nil })
#expect(queue.current == nil)
#expect(workspace.keyboardAuthentication.current == nil)
#expect(transfers.sftpTransfers.isEmpty)
}
@@ -0,0 +1,89 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileForwardingLifecycleTests {
@Test func backgroundExpirationCancelsSharedAuthenticationAndForegroundNeverRestartsIt() async throws {
let background = ForwardingBackgroundTask()
let app = try MobileAppModel(inMemory: true, background: background)
let host = TermindDomain.Host(name: "Server", host: "server.invalid")
app.library.upsert(host)
let rule = PortForwardingRule(hostID: host.id)
try app.forwardingLibrary.save(rule)
let firstScene = UUID()
let secondScene = UUID()
defer {
app.forwardings.stopAll()
app.removeScene(firstScene)
app.removeScene(secondScene)
}
app.sceneChanged(firstScene, phase: .active)
app.sceneChanged(secondScene, phase: .active)
app.forwardings.start(rule.id)
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
let context = try #require(app.forwardings.connection(for: rule.id))
app.sceneChanged(firstScene, phase: .background)
#expect(background.expiration == nil)
#expect(context.authenticationPrompt != nil)
app.sceneChanged(secondScene, phase: .background)
let expire = try #require(background.expiration)
expire()
#expect(context.authenticationPrompt == nil)
#expect(app.forwardings.state(for: rule.id) == .stopped)
app.sceneChanged(secondScene, phase: .active)
#expect(app.forwardings.connection(for: rule.id) == nil)
#expect(app.forwardings.state(for: rule.id) == .stopped)
}
@Test func syncedRulesAndJumpHostChangesStopAffectedRunsWithoutRestarting() async throws {
let app = try MobileAppModel(inMemory: true)
defer { app.forwardings.stopAll() }
let jump = TermindDomain.Host(name: "Jump", host: "jump.invalid")
let host = TermindDomain.Host(name: "Server", host: "server.invalid", jumpHostID: jump.id)
app.library.upsert(jump)
app.library.upsert(host)
var rule = PortForwardingRule(hostID: host.id)
try app.forwardingLibrary.save(rule)
app.forwardings.start(rule.id)
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
rule.bindPort = 9090
app.applyRemoteVault(
.init(hosts: [jump, host], portForwardings: [rule]),
keys: [VaultRecordKey(kind: .portForwarding, id: rule.id)])
#expect(app.forwardings.state(for: rule.id) == .stopped)
#expect(app.forwardings.connection(for: rule.id) == nil)
app.forwardings.start(rule.id)
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
var changedJump = jump
changedJump.port = 2222
app.applyRemoteVault(
.init(hosts: [changedJump, host], portForwardings: [rule]),
keys: [VaultRecordKey(kind: .host, id: jump.id)])
#expect(app.forwardings.state(for: rule.id) == .stopped)
#expect(app.forwardings.connection(for: rule.id) == nil)
app.forwardings.start(rule.id)
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
app.deleteHost(jump.id)
#expect(app.forwardings.state(for: rule.id) == .stopped)
#expect(app.forwardings.connection(for: rule.id) == nil)
app.outbox.stop()
}
private func wait(_ condition: () -> Bool) async throws {
for _ in 0..<400 {
if condition() { return }
try await Task.sleep(for: .milliseconds(5))
}
throw CocoaError(.coderInvalidValue)
}
}
@MainActor private final class ForwardingBackgroundTask: MobileBackgroundTaskManaging {
var expiration: (@MainActor () -> Void)?
func begin(expiration: @escaping @MainActor () -> Void) { self.expiration = expiration }
func finish() { expiration = nil }
}
@@ -0,0 +1,136 @@
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileForwardingRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func forwardingUsesVaultCardsAndInPageAuthenticationWithoutASheet(_ language: String) async throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
app.library.upsert(host)
let rule = PortForwardingRule(hostID: host.id, label: "Database", colorTag: .teal)
let unnamed = PortForwardingRule(hostID: host.id, bindPort: 15432)
for candidate in [
rule,
PortForwardingRule(hostID: host.id, kind: .remote, bindPort: 9090, label: "Preview", colorTag: .orange),
PortForwardingRule(hostID: host.id, kind: .dynamic, bindPort: 1080, label: "SOCKS5"),
unnamed,
] {
try app.forwardingLibrary.save(candidate)
}
let route = ForwardingRenderRoute()
var features = MobileFeatureRegistry()
features.register(
MobileFeatureRegistration(id: .vault, title: "Vault", symbol: "server.rack") { app, scene in
AnyView(
MobileForwardingView(app: app, reviewScene: scene)
.navigationDestination(
isPresented: Binding(get: { route.details }, set: { route.details = $0 })
) {
MobileForwardingDetailView(app: app, ruleID: route.unnamed ? unnamed.id : rule.id)
}
.sheet(isPresented: Binding(get: { route.editing }, set: { route.editing = $0 })) {
MobileForwardingEditor(app: app, rule: rule, isNew: false)
})
})
let scene = MobileSceneModel(app: app)
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = windowScene.keyWindow
let window = UIWindow(windowScene: windowScene)
let container = UIViewController()
let controller = UIHostingController(
rootView: MobileRootView(app: app, scene: scene, features: features)
.environment(\.locale, Locale(identifier: language)).environment(\.scenePhase, .active))
container.addChild(controller)
container.view.addSubview(controller.view)
controller.didMove(toParent: container)
window.rootViewController = container
window.windowLevel = .alert + 3
window.makeKeyAndVisible()
defer {
app.forwardings.stopAll()
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
resize(controller, width: 430, height: 900)
try await Task.sleep(for: .milliseconds(300))
try capture(controller.view, name: "cards-\(language)")
route.editing = true
try await Task.sleep(for: .milliseconds(650))
try capture(try #require(presented(in: controller)?.view), name: "editor-\(language)")
route.editing = false
try await Task.sleep(for: .milliseconds(650))
route.details = true
try await Task.sleep(for: .milliseconds(650))
try capture(controller.view, name: "detail-stopped-\(language)")
route.unnamed = true
try await Task.sleep(for: .milliseconds(400))
try capture(controller.view, name: "detail-unnamed-\(language)")
route.details = false
try await Task.sleep(for: .milliseconds(650))
route.unnamed = false
app.forwardings.start(rule.id)
for _ in 0..<400 where app.forwardings.connection(for: rule.id)?.authenticationPrompt == nil {
try await Task.sleep(for: .milliseconds(5))
}
let context = try #require(app.forwardings.connection(for: rule.id))
let prompt = try #require(context.authenticationPrompt)
#expect(!route.details)
#expect(presented(in: controller) == nil)
route.details = true
for size in [
CGSize(width: 430, height: 900), CGSize(width: 900, height: 430), CGSize(width: 1024, height: 900),
] {
resize(controller, width: size.width, height: size.height)
try await Task.sleep(for: .milliseconds(650))
#expect(context.authenticationPrompt?.id == prompt.id)
#expect(presented(in: controller) == nil)
try capture(controller.view, name: "journey-\(language)-\(Int(size.width))")
}
route.details = false
try await Task.sleep(for: .milliseconds(300))
#expect(context.authenticationPrompt?.id == prompt.id)
route.details = true
controller.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
resize(controller, width: 430, height: 900)
try await Task.sleep(for: .milliseconds(300))
try capture(controller.view, name: "journey-\(language)-large-type")
app.forwardings.stop(rule.id)
#expect(context.authenticationPrompt == nil)
#expect(app.forwardings.state(for: rule.id) == .stopped)
}
private func resize(_ controller: UIViewController, width: CGFloat, height: CGFloat) {
let frame = CGRect(x: 0, y: 0, width: width, height: height)
controller.view.window?.frame = frame
controller.parent?.view.frame = frame
controller.traitOverrides.horizontalSizeClass = width >= 600 ? .regular : .compact
controller.view.frame = frame
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
}
private func presented(in controller: UIViewController) -> UIViewController? {
controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first
}
private func capture(_ view: UIView, name: String) throws {
let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in
view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
}
Attachment.record(data, named: "mobile-forwarding-\(name).png")
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-forwarding-ui")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: "mobile-forwarding-\(name).png"))
}
}
@Observable @MainActor private final class ForwardingRenderRoute {
var details = false
var unnamed = false
var editing = false
}
+3 -3
View File
@@ -33,7 +33,8 @@ struct MobileIconRenderingTests {
let app = try MobileAppModel(inMemory: true)
let forwardingHost = Host(name: "Example", host: "example.invalid")
let forwardingRule = PortForwardingRule(hostID: forwardingHost.id, label: "Web")
#expect(app.library.upsert(forwardingHost, replacingPortForwardings: [forwardingRule]))
app.library.upsert(forwardingHost)
#expect(app.library.upsert(forwardingRule))
let proxyHost = Host(
name: "Proxied", host: "proxied.example.invalid",
proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080))
@@ -144,8 +145,7 @@ struct MobileIconRenderingTests {
}
try await capture("forwarding-editor-\(suffix)", language: language, scheme: scheme) {
MobileForwardingEditor(
app: app, host: forwardingHost, rule: forwardingRule, isNew: false,
save: { _ in }
app: app, rule: forwardingRule, isNew: false
)
.environment(\.locale, locale)
}
-3
View File
@@ -56,7 +56,6 @@ struct MobileMetricsJourneyTests {
try await wait { server.keyboardAuthentication.current != nil }
let challenge = try #require(server.keyboardAuthentication.current)
#expect(!route.showsDetails)
#expect(app.keyboardAuthentication.pending.isEmpty)
route.showsDetails = true
for width in [430.0, 600.0, 1024.0] {
resize(controller, width: width)
@@ -67,7 +66,6 @@ struct MobileMetricsJourneyTests {
#expect(descendants(UISegmentedControl.self, in: controller.view).isEmpty)
#expect(presented(in: controller) == nil)
#expect(server.keyboardAuthentication.current?.id == challenge.id)
#expect(app.keyboardAuthentication.pending.isEmpty)
try capture(controller.view, name: "metrics-journey-\(language)-\(Int(width))")
}
route.showsDetails = false
@@ -94,7 +92,6 @@ struct MobileMetricsJourneyTests {
try await wait { server.point != nil && !server.showsConnectionJourney }
try await Task.sleep(for: .milliseconds(400))
#expect(descendants(UITextField.self, in: controller.view).isEmpty)
#expect(app.keyboardAuthentication.pending.isEmpty)
#expect(presented(in: controller) == nil)
#expect(await sampler.answers == ["deploy", "test-code"])
#expect(await sampler.connections == 1)
+22 -35
View File
@@ -12,16 +12,16 @@ import Testing
struct MobileVaultFeatureTests {
@Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() {
var host = TermindDomain.Host(name: "Remote", host: "example.invalid")
#expect(MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(MobileHostEditorPolicy.canSave(host))
host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: nil)
#expect(host.proxy?.port == 8080)
#expect(!MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(!MobileHostEditorPolicy.canSave(host))
host.proxy?.hostname = "proxy.example"
#expect(MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(MobileHostEditorPolicy.canSave(host))
host.proxy?.port = 0
#expect(!MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(!MobileHostEditorPolicy.canSave(host))
host.proxy?.port = 8080
host.proxy = MobileHostEditorPolicy.selectProxy(.socks5, current: host.proxy)
#expect(host.proxy?.port == 1080)
@@ -36,14 +36,14 @@ struct MobileVaultFeatureTests {
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: host.proxy)
#expect(host.proxy?.identityID == identity.id)
#expect(host.proxy?.username == "proxy-user")
#expect(MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(MobileHostEditorPolicy.canSave(host))
host.proxy?.username = ""
#expect(!MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(!MobileHostEditorPolicy.canSave(host))
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: host.proxy)
#expect(host.proxy?.identityID == nil)
#expect(host.proxy?.username.isEmpty == true)
#expect(MobileHostEditorPolicy.canSave(host, forwardings: []))
#expect(MobileHostEditorPolicy.canSave(host))
host.proxy = MobileHostEditorPolicy.selectProxy(nil, current: host.proxy)
#expect(host.proxy == nil)
@@ -66,38 +66,25 @@ struct MobileVaultFeatureTests {
#expect(!app.library.isProxyEligibleOnThisDevice(keyOnly))
}
@Test func forwardingEnabledIntentUsesInjectedDevicePreferences() throws {
let suite = "MobileForwardingIntentTests.\(UUID().uuidString)"
@Test func savingAndForegroundingNeverStartForwardings() throws {
let suite = "MobileManualForwardingTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let app = try MobileAppModel(inMemory: true, forwardingDefaults: defaults)
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
let rule = PortForwardingRule(hostID: host.id)
#expect(app.library.upsert(host, replacingPortForwardings: [rule]))
app.forwardings.setEnabled(true, for: rule.id)
#expect(app.forwardings.isEnabled(rule.id))
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString])
app.forwardings.suspendAll()
#expect(app.forwardings.isEnabled(rule.id))
app.forwardings.setEnabled(false, for: rule.id)
#expect(!app.forwardings.isEnabled(rule.id))
}
@Test func unsavedForwardingIntentWaitsUntilTheHostIsSaved() throws {
let suite = "MobilePendingForwardingIntentTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let app = try MobileAppModel(inMemory: true, forwardingDefaults: defaults)
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
let rule = PortForwardingRule(hostID: host.id)
app.forwardings.setEnabled(true, for: rule.id)
#expect(app.forwardings.isEnabled(rule.id))
defaults.set([rule.id.uuidString], forKey: "portForwarding.enabledRuleIDs")
let app = try MobileAppModel(inMemory: true, defaults: defaults)
app.library.upsert(host)
try app.forwardingLibrary.save(rule)
let sceneID = UUID()
app.sceneChanged(sceneID, phase: .active)
#expect(app.forwardings.state(for: rule.id) == .stopped)
#expect(app.library.upsert(host, replacingPortForwardings: [rule]))
#expect(app.forwardings.state(for: rule.id) != .stopped)
app.forwardings.setEnabled(false, for: rule.id)
#expect(app.forwardings.connection(for: rule.id) == nil)
app.forwardings.stopAll()
app.sceneChanged(sceneID, phase: .background)
app.sceneChanged(sceneID, phase: .active)
#expect(app.forwardings.state(for: rule.id) == .stopped)
app.removeScene(sceneID)
}
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
@@ -318,7 +305,7 @@ struct MobileVaultFeatureTests {
app.library.upsert(host)
var rule = PortForwardingRule(hostID: host.id)
try app.forwardingLibrary.save(rule)
#expect(!app.forwardings.isEnabled(rule.id))
#expect(app.forwardings.state(for: rule.id) == .stopped)
var stopped: [UUID] = []
app.library.willDeletePortForwardings = { stopped.append(contentsOf: $0) }
rule.bindPort = 9000
-3
View File
@@ -18,7 +18,6 @@ struct AppDependencies {
let metricsSync: MetricsSyncCoordinator?
let vaultSync: VaultSyncCoordinator?
let metricsPreferences: (any MetricsConfigurationStoring)?
let forwardingDefaults: UserDefaults?
let mcpServerSecrets: any SecretStoring
init(
@@ -30,7 +29,6 @@ struct AppDependencies {
settingsSync: SettingsSyncCoordinator? = nil,
metricsSync: MetricsSyncCoordinator? = nil,
metricsPreferences: (any MetricsConfigurationStoring)? = nil,
forwardingDefaults: UserDefaults? = nil,
mcpServerSecrets: any SecretStoring = InMemorySecretStore()
) {
self.hostRepository = hostRepository
@@ -42,7 +40,6 @@ struct AppDependencies {
self.settingsSync = settingsSync
self.metricsSync = metricsSync
self.metricsPreferences = metricsPreferences
self.forwardingDefaults = forwardingDefaults
self.mcpServerSecrets = mcpServerSecrets
}
+11 -11
View File
@@ -185,9 +185,7 @@ final class AppModel {
vaultSync = dependencies.vaultSync
settingsSync = dependencies.settingsSync
metricsSync = dependencies.metricsSync
forwarding = PortForwardingController(
hosts: hosts, defaults: dependencies.forwardingDefaults,
keyboardInteractive: externalAuthentication.keyboardInteractive)
forwarding = PortForwardingController(hosts: hosts)
sftpTransfers = SFTPTransferCoordinator(
store: hosts,
clientFactory: { connectionCoordinator.makeSFTPClient() }
@@ -224,18 +222,17 @@ final class AppModel {
sftpEdits?.invalidate(hostID: hostID)
mcpServer?.tools.revokeHost(hostID)
serverMetrics?.remove(hostID)
forwarding?.suspend(ruleIDs)
forwarding?.hostConfigurationChanged(hostID)
forwarding?.stop(ruleIDs)
// Not a `HostDeletionOutbox` step like the others: a live socket
// cannot outlive the process, so there is nothing to replay after a
// crash and nothing to make durable.
Task { await agentConnections.disconnect(hostID: hostID) }
}
hosts.willDeletePortForwardings = { [weak forwarding] ruleIDs in
forwarding?.suspend(ruleIDs)
}
hosts.didReplacePortForwardings = { [weak forwarding] ruleIDs in
forwarding?.resumeEnabled(ruleIDs)
forwarding?.stop(ruleIDs)
}
hosts.didChangeHostConnection = { [weak forwarding] in forwarding?.hostConfigurationChanged($0) }
hosts.didPersistCore = { [weak repository = dependencies.hostRepository] in
try? repository?.refresh()
}
@@ -261,6 +258,7 @@ final class AppModel {
if let self { Task { await self.mcpServer.tools.cancelHostTransfers(deletion.hostID) } }
},
stopForwardings: { [weak self] deletion in
self?.forwarding.hostConfigurationChanged(deletion.hostID)
self?.forwarding.remove(deletion.forwardingRuleIDs)
}
)
@@ -326,9 +324,9 @@ final class AppModel {
connectionHistory.clear(for: change.id)
}
}
if !plan.forwardingRuleIDs.isEmpty { forwarding.suspend(plan.forwardingRuleIDs) }
if !plan.forwardingRuleIDs.isEmpty { forwarding.stop(plan.forwardingRuleIDs) }
hosts.replacePersistedSnapshot(snapshot)
forwarding.resumeEnabled()
forwarding.reconcileConfiguration()
}
@ObservationIgnored private lazy var assistantOperations = AssistantConversationOperations(
@@ -573,6 +571,8 @@ final class AppModel {
func requestHostDeletion(_ hostID: Host.ID) -> Bool {
do {
try hostRepository.requestHostDeletion(hostID)
forwarding.hostConfigurationChanged(hostID)
forwarding.remove(hosts.portForwardings(for: hostID).map(\.id))
commandHistory.clear(for: hostID)
sftpHistory.clear(for: hostID)
connectionHistory.clear(for: hostID)
@@ -630,7 +630,7 @@ final class AppModel {
for window in liveWindows { window.closeAll() }
sftpTransfers.cancelAll()
externalAuthentication.cancelAll()
forwarding.suspendAll()
forwarding.stopAll()
let task = Task { [mcpServer, sftpTransfers, agentConnections, agentBackgroundCommands] in
async let stopMCP: Void = mcpServer.stop()
async let stopSFTP: Void = sftpTransfers.waitForCancellation()
+1 -6
View File
@@ -115,7 +115,7 @@ struct TermindApp: App {
),
vaultSync: vaultSync, settingsSync: settingsSync,
metricsSync: metricsSync, metricsPreferences: metricsPersistence,
forwardingDefaults: .standard, mcpServerSecrets: secrets
mcpServerSecrets: secrets
),
proAccess: proAccess,
appReview: .live(),
@@ -185,10 +185,6 @@ struct TermindApp: App {
didSet {
guard let app else { return }
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
if !didRestoreForwardings {
didRestoreForwardings = true
app.forwarding.resumeEnabled()
}
app.serverMetrics.start()
metricsActivity.start(app.serverMetrics)
metricsMenu.openMainWindow = { [weak app] in app?.mainWindows.openMetrics() }
@@ -197,7 +193,6 @@ struct TermindApp: App {
}
private let metricsActivity = MetricsActivityObserver()
private let metricsMenu = MetricsMenuBarController()
private var didRestoreForwardings = false
private var awaitingTermination = false
private var repliedToTermination = false
private var quietLaunchObserver: (any NSObjectProtocol)?
@@ -18,21 +18,3 @@ extension KeyStorage {
}
}
}
extension PortForwardingRule.Kind {
nonisolated var displayName: String {
switch self {
case .local: String(localized: "Local forward (-L)")
case .remote: String(localized: "Remote forward (-R)")
case .dynamic: String(localized: "Dynamic proxy (-D)")
}
}
nonisolated var symbol: String {
switch self {
case .local: "arrow.right"
case .remote: "arrow.left"
case .dynamic: "shuffle"
}
}
}
+2 -31
View File
@@ -13,7 +13,6 @@ struct HostEditorView: View {
case identity
case proxyIdentity
case group
case forwarding(PortForwardingRule)
}
let hostID: Host.ID?
@@ -22,7 +21,6 @@ struct HostEditorView: View {
@Environment(WindowModel.self) private var window
@State private var draft = Host(name: "", host: "")
@State private var draftForwardings: [PortForwardingRule] = []
@State private var route: Route?
@State private var saveError: String?
@State private var didLoad = false
@@ -146,11 +144,6 @@ struct HostEditorView: View {
}
}
PortForwardingEditorSection(
host: draft, rules: $draftForwardings,
openEditor: { route = .forwarding($0) }
)
Section("Group & Tags") {
Picker("Group", selection: groupChoice) {
Text("None").tag(Choice<HostGroup.ID>.unset)
@@ -227,14 +220,7 @@ struct HostEditorView: View {
draft.proxy?.identityID = identity.id
draft.proxy?.username = identity.username
}
case .forwarding(let rule):
PortForwardingEditorSheet(
forwarding: rule, host: draft,
isNew: !draftForwardings.contains { $0.id == rule.id },
back: closeNestedEditor,
delete: { draftForwardings.removeAll { $0.id == rule.id } },
save: upsertForwardingDraft
)
}
}
@@ -242,14 +228,6 @@ struct HostEditorView: View {
route = nil
}
private func upsertForwardingDraft(_ rule: PortForwardingRule) {
if let index = draftForwardings.firstIndex(where: { $0.id == rule.id }) {
draftForwardings[index] = rule
} else {
draftForwardings.append(rule)
}
}
// MARK: - Picking, and making
/// What a picker row means: nothing, one of the saved records, or the row
@@ -442,13 +420,11 @@ struct HostEditorView: View {
&& (draft.sftpSessionInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| draft.sftpSessionInitialPath.hasPrefix("/"))
&& app.hostLibrary.validatedRoute(for: draft) != nil
&& draftForwardings.allSatisfy(\.isValid)
}
private func load() {
guard let hostID, let existing = app.hostLibrary.host(hostID) else { return }
draft = existing
draftForwardings = app.hostLibrary.portForwardings(for: hostID)
}
private func loadOnce() {
@@ -465,11 +441,7 @@ struct HostEditorView: View {
}
let initialPath = draft.sftpSessionInitialPath.trimmingCharacters(in: .whitespacesAndNewlines)
draft.sftpSessionInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath)
for index in draftForwardings.indices { draftForwardings[index].hostID = draft.id }
if app.hostLibrary.upsert(
draft,
replacingPortForwardings: draftForwardings
) {
if app.hostLibrary.saveHost(draft) {
closeTab()
} else {
saveError = app.hostLibrary.storageError ?? String(localized: "The host could not be saved.")
@@ -477,7 +449,6 @@ struct HostEditorView: View {
}
private func cancel() {
app.forwarding.discardPending(draftForwardings.map(\.id))
closeTab()
}
+231 -272
View File
@@ -4,320 +4,279 @@ import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
@Observable
final class PortForwardingPageState {
var query = ""
var deleting: Set<UUID> = []
var error: String?
}
/// Host-scoped forwarding management embedded in `HostEditorView`.
struct PortForwardingEditorSection: View {
let host: Host
@Binding var rules: [PortForwardingRule]
let openEditor: (PortForwardingRule) -> Void
struct PortForwardingPageView: ManagedCardPage {
typealias SectionID = String
typealias Item = PortForwardingRule
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
let state: PortForwardingPageState
var body: some View {
forwardingSection
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 }
private var forwardingSection: some View {
Section("Port Forwarding") {
ForEach(rules) { rule in
HostForwardingRow(
host: host,
rule: rule,
edit: { openEditor(rule) },
duplicate: { duplicate(rule) },
delete: { delete(rule) }
)
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: 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))
}
Button("Edit", systemImage: MenuSymbol.edit) { open(item) }
Button("Duplicate", systemImage: MenuSymbol.duplicate) { duplicate(item) }
Button("Copy Address", systemImage: MenuSymbol.copy) { copyToPasteboard(item.bindAddress) }
Button("Copy SSH Command") {
if let command = app.forwardings.sshCommand(for: item) { copyToPasteboard(command) }
}.disabled(app.forwardings.sshCommand(for: item) == nil)
}
Divider()
Button("Delete", systemImage: MenuSymbol.delete, role: .destructive) {
state.deleting = Set(context.actionItems.map(\.id))
}
}
FormNavigationAction(title: "Add Forwarding") {
openEditor(PortForwardingRule(hostID: host.id))
}
if !rules.isEmpty {
forwardingActions
}
Text(
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
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 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: PortForwardingPagePresentation {
PortForwardingPagePresentation(
state: state, ids: app.forwardings.rules.map(\.id),
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
delete: {
do { try app.forwardings.delete(state.deleting) } catch { state.error = error.localizedDescription }
state.deleting = []
})
}
private var forwardingActions: some View {
HStack(spacing: Metrics.Space.m) {
Spacer()
Button("Enable All", action: enableAll)
.disabled(enableableRules.isEmpty)
Button("Disable All", action: disableAll)
.disabled(runningRules.isEmpty)
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.label, symbol: runtime.isActive ? "circle.fill" : "circle",
tint: runtime.isActive ? .green : nil)
}
}
}
}
private var enableableRules: [PortForwardingRule] {
rules.filter { !app.forwarding.isEnabled($0.id) }
private func canStart(_ rule: Item) -> Bool {
PortForwardingEditorPolicy(
rule: nil, savedRule: rule, savedHost: app.hostLibrary.host(rule.hostID),
isRuntimeActive: app.forwarding.state(for: rule.id).isActive
).canStart
}
private var runningRules: [PortForwardingRule] {
rules.filter { app.forwarding.isEnabled($0.id) || app.forwarding.state(for: $0.id).isActive }
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: PortForwardingRule) {
private func duplicate(_ rule: Item) {
var copy = rule
copy.id = UUID()
if !copy.label.isEmpty { copy.label += String(localized: " Copy") }
rules.append(copy)
}
private func delete(_ rule: PortForwardingRule) {
app.forwarding.discardPending([rule.id])
rules.removeAll { $0.id == rule.id }
}
private func enableAll() {
app.forwarding.setEnabled(true, for: enableableRules.map(\.id))
}
private func disableAll() {
app.forwarding.setEnabled(false, for: runningRules.map(\.id))
do {
try app.forwardings.save(copy)
open(copy)
} catch { state.error = error.localizedDescription }
}
}
private struct HostForwardingRow: View {
let host: Host
let rule: PortForwardingRule
let edit: () -> Void
let duplicate: () -> Void
struct PortForwardingPagePresentation: ManagedCardPresentationModifier {
@Bindable var state: PortForwardingPageState
let ids: [UUID]
let prune: () -> Void
let delete: () -> Void
@Environment(AppModel.self) private var app
private var state: PortForwardingEditorPolicy {
PortForwardingEditorPolicy(
rule: PortForwardingEditorPolicy.normalized(rule),
savedRule: app.forwardings.portForwarding(rule.id),
savedHost: app.hostLibrary.host(host.id),
isEnabled: app.forwarding.isEnabled(rule.id),
isRuntimeActive: runtime.isActive)
}
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: rule.id) }
private var title: String { rule.label.isEmpty ? rule.kind.displayName : rule.label }
var body: some View {
HStack(spacing: Metrics.Space.m) {
Image(systemName: rule.kind.symbol)
.foregroundStyle(runtime.isActive ? Color.green : Color.secondary)
.frame(width: Metrics.Sidebar.rowIcon)
VStack(alignment: .leading, spacing: 1) {
Text(title).lineLimit(1)
Text(rule.summary)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
func body(content: Content) -> some View {
content.onAppear(perform: prune).onChange(of: ids) { prune() }
.confirmationDialog(
"Delete Forwarding Rules?",
isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } })
) {
Button("Delete", role: .destructive, action: delete)
}
Spacer(minLength: Metrics.Space.m)
statusChip
Toggle("", isOn: enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.mini)
.disabled(!state.canToggle)
.accessibilityLabel(Text("Enable \(title)"))
Button(action: edit) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.alert(
"Operation Failed",
isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } })
) {
Button("OK") { state.error = nil }
} message: {
Text(state.error ?? "")
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Edit \(title)"))
}
.contentShape(.rect)
.onTapGesture(perform: edit)
.contextMenu {
HostForwardingContextMenu(
host: host, rule: rule,
edit: edit, duplicate: duplicate, delete: delete
)
}
.help(Text(rule.summary))
}
@ViewBuilder
private var statusChip: some View {
if state.isWaitingForSave {
CardChip(text: String(localized: "Waiting for Save"), symbol: "clock")
} else {
CardChip(
text: runtime.label,
symbol: runtime.isActive ? "circle.fill" : "circle",
tint: runtime.isActive ? Color.green : nil
)
}
}
private var enabled: Binding<Bool> {
Binding(
get: { state.isEnabled || state.isRuntimeActive },
set: { value in
app.forwarding.setEnabled(value, for: rule.id)
}
)
}
}
private struct HostForwardingContextMenu: View {
let host: Host
let rule: PortForwardingRule
let edit: () -> Void
let duplicate: () -> Void
let delete: () -> Void
struct PortForwardingDetailView: View {
let ruleID: UUID?
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var draft = PortForwardingRule(hostID: UUID())
@State private var loaded = false
@State private var error: String?
private var state: PortForwardingEditorPolicy {
PortForwardingEditorPolicy(
rule: PortForwardingEditorPolicy.normalized(rule),
savedRule: app.forwardings.portForwarding(rule.id),
savedHost: app.hostLibrary.host(host.id),
isEnabled: app.forwarding.isEnabled(rule.id),
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
}
private var isActive: Bool { !state.canEdit }
private var command: String? { app.forwardings.sshCommand(for: rule, host: host) }
var body: some View {
let isRunning = state.isEnabled || state.isRuntimeActive
Button(
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
systemImage: isRunning ? MenuSymbol.stop : MenuSymbol.start,
action: toggle
)
.disabled(!state.canToggle)
Button("Edit", systemImage: MenuSymbol.edit, action: edit)
Button("Duplicate", systemImage: MenuSymbol.duplicate, action: duplicate)
.disabled(isActive)
// Copy SSH Command repeats the verb above it, so only the first carries
// the glyph.
Button("Copy Address", systemImage: MenuSymbol.copy, action: copyAddress)
.disabled(isActive)
Button("Copy SSH Command", action: copyCommand)
.disabled(isActive || command == nil)
Divider()
DestructiveMenuButton("Delete", action: delete).disabled(isActive)
}
private func toggle() {
if state.isEnabled || state.isRuntimeActive {
app.forwarding.setEnabled(false, for: rule.id)
} else {
app.forwarding.setEnabled(true, for: rule.id)
}
}
private func copyAddress() { copyToPasteboard(rule.bindAddress) }
private func copyCommand() { if let command { copyToPasteboard(command) } }
}
struct PortForwardingEditorSheet: View {
let host: Host
let isNew: Bool
let back: () -> Void
let delete: () -> Void
let save: (PortForwardingRule) -> Void
@Environment(AppModel.self) private var app
@State private var draft: PortForwardingRule
init(
forwarding: PortForwardingRule, host: Host, isNew: Bool,
back: @escaping () -> Void,
delete: @escaping () -> Void,
save: @escaping (PortForwardingRule) -> Void
) {
self.host = host
self.isNew = isNew
self.back = back
self.delete = delete
self.save = save
_draft = State(initialValue: forwarding)
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)
}
var body: some View {
VaultDetailScaffold(
title: isNew ? "New Forwarding" : "Edit Forwarding",
symbol: "arrow.left.arrow.right", close: cancel,
closeLabel: "Back", closeSymbol: "chevron.backward"
title: ruleID == nil
? "New Forwarding" : "\(draft.displayName(hostName: app.hostLibrary.host(draft.hostID)?.name))",
symbol: "arrow.left.arrow.right", close: window.closeVaultWork
) {
Form {
Section {
Toggle("Enabled", isOn: enabled)
.disabled(!state.canToggle)
LabeledContent(
"Status",
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : runtime.label
)
if state.isWaitingForSave {
Text("This forwarding will start after the host is saved.")
.font(.caption)
.foregroundStyle(.secondary)
if let context = app.forwarding.connection(for: draft.id), 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)
}
}
}
}
PortForwardingFields(rule: $draft)
.disabled(!state.canEdit)
PortForwardingHostPicker(rule: $draft, hosts: app.forwardings.hosts)
.disabled(runtime.isActive)
PortForwardingFields(rule: $draft).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)
}
.formStyle(.grouped)
} footer: {
HStack {
if !isNew {
Button("Delete", role: .destructive) {
guard state.canEdit else { return }
delete()
back()
}
.disabled(!state.canEdit)
}
if runtime.isActive { Button("Stop") { app.forwarding.stop(draft.id) } }
Spacer()
Button("Cancel", action: cancel).keyboardShortcut(.cancelAction)
Button("Save") { commit() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(normalizedDraft == nil || !state.canEdit)
}
.padding(Metrics.Space.l)
Button("Close", action: window.closeVaultWork)
Button("Save", action: save).buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction).disabled(!canSave)
}.padding(Metrics.Space.l)
}
.onAppear {
guard !loaded else { return }
loaded = true
if let saved { draft = saved }
}
}
private var normalizedDraft: PortForwardingRule? {
PortForwardingEditorPolicy.normalized(draft)
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)
}
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: draft.id) }
private var state: PortForwardingEditorPolicy {
PortForwardingEditorPolicy(
rule: normalizedDraft,
savedRule: app.forwardings.portForwarding(draft.id),
savedHost: app.hostLibrary.host(host.id),
isEnabled: app.forwarding.isEnabled(draft.id),
isRuntimeActive: runtime.isActive)
}
private var enabled: Binding<Bool> {
Binding(
get: { state.isEnabled || state.isRuntimeActive },
set: { value in
app.forwarding.setEnabled(value, for: draft.id)
})
}
private func cancel() {
if isNew { app.forwarding.discardPending([draft.id]) }
back()
app.forwarding.stop(draft.id)
window.closeVaultWork()
}
private func commit() {
guard state.canEdit, let value = normalizedDraft else { return }
save(value)
back()
private func save() {
guard canSave, let normalized else { return }
do {
try app.forwardings.save(normalized)
window.selectVaultItem(.forwarding(normalized.id), on: .portForwarding)
window.openVaultWork(.forwardingEditor(normalized.id), on: .portForwarding)
draft = normalized
error = nil
} catch { self.error = error.localizedDescription }
}
}
+8
View File
@@ -10,6 +10,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
case welcome
case hosts
case keychain
case portForwarding
case snippets
case knownHosts
@@ -20,6 +21,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
case .welcome: "Welcome"
case .hosts: "Hosts"
case .keychain: "Keychain"
case .portForwarding: "Port Forwarding"
case .snippets: "Snippets"
case .knownHosts: "Known Hosts"
}
@@ -30,6 +32,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
case .welcome: AppLocalization.string("Welcome", locale: locale)
case .hosts: AppLocalization.string("Hosts", locale: locale)
case .keychain: AppLocalization.string("Keychain", locale: locale)
case .portForwarding: AppLocalization.string("Port Forwarding", locale: locale)
case .snippets: AppLocalization.string("Snippets", locale: locale)
case .knownHosts: AppLocalization.string("Known Hosts", locale: locale)
}
@@ -40,6 +43,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
case .welcome: "house"
case .hosts: "server.rack"
case .keychain: "key.fill"
case .portForwarding: "arrow.left.arrow.right"
case .snippets: "curlybraces"
case .knownHosts: "touchid"
}
@@ -50,6 +54,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
case welcome
case hosts
case keychain
case portForwarding
case snippets
case knownHosts
@@ -58,6 +63,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
case .welcome: self = .welcome
case .hosts: self = .hosts
case .keychain: self = .keychain
case .portForwarding: self = .portForwarding
case .snippets: self = .snippets
case .knownHosts: self = .knownHosts
}
@@ -68,6 +74,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
case .welcome: .welcome
case .hosts: .hosts
case .keychain: .keychain
case .portForwarding: .portForwarding
case .snippets: .snippets
case .knownHosts: .knownHosts
}
@@ -80,6 +87,7 @@ nonisolated enum VaultDestination: Hashable, Sendable {
nonisolated enum VaultWorkRoute: Hashable, Sendable {
case hostEditor(Host.ID?)
case hostGroupEditor(groupID: HostGroup.ID?, parentID: HostGroup.ID?)
case forwardingEditor(PortForwardingRule.ID?)
case identityEditor(Identity.ID?)
case generateKey
case importKey
+8
View File
@@ -9,6 +9,7 @@ nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable {
static let host = "host"
static let key = "key"
static let identity = "identity"
static let forwarding = "forwarding"
static let snippet = "snippet"
static let knownHost = "knownHost"
}
@@ -40,6 +41,11 @@ nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable {
Self(kind: Kind.knownHost, rawID: id, page: .knownHosts)
}
nonisolated static func forwarding(_ id: PortForwardingRule.ID) -> Self {
Self(kind: Kind.forwarding, rawID: id, page: .portForwarding)
}
var forwardingID: PortForwardingRule.ID? { kind == Kind.forwarding ? rawID : nil }
var knownHostID: KnownHostEntry.ID? { kind == Kind.knownHost ? rawID : nil }
var hostID: Host.ID? { kind == Kind.host ? rawID : nil }
var keyID: SSHKeyRef.ID? { kind == Kind.key ? rawID : nil }
@@ -94,6 +100,7 @@ final class VaultSelectionState {
final class VaultPageStates {
let hosts = HostsPageState()
let keychain = KeychainPageState()
let portForwarding = PortForwardingPageState()
let snippets = SnippetsPageState()
let knownHosts = KnownHostsPageState()
let selection = VaultSelectionState()
@@ -105,6 +112,7 @@ final class VaultPageStates {
case .welcome: ""
case .hosts: hosts.query
case .keychain: keychain.query
case .portForwarding: portForwarding.query
case .snippets: snippets.query
case .knownHosts: knownHosts.query
}
+3
View File
@@ -21,6 +21,7 @@ struct VaultView: View {
case .welcome: WelcomeView()
case .hosts: HostsPageView(state: pages.hosts)
case .keychain: KeychainPageView(state: pages.keychain)
case .portForwarding: PortForwardingPageView(state: pages.portForwarding)
case .snippets: SnippetsPageView(state: pages.snippets)
case .knownHosts: KnownHostsPageView(state: pages.knownHosts)
}
@@ -47,6 +48,8 @@ struct VaultWorkDetailView: View {
},
close: window.closeVaultWork
)
case .forwardingEditor(let id):
PortForwardingDetailView(ruleID: id)
case .identityEditor(let id):
NewIdentitySheet(
identity: id.flatMap(app.hostLibrary.identity),
@@ -187,6 +187,7 @@ final class WindowModel {
Set(store.hosts.map { .host($0.id) })
.union(store.keys.map { .key($0.id) })
.union(store.identities.map { .identity($0.id) })
.union(store.portForwardings.map { .forwarding($0.id) })
.union(store.snippets.map { .snippet($0.id) })
.union(store.knownHosts.map { .knownHost($0.id) })
}
@@ -414,6 +415,7 @@ final class WindowModel {
case .welcome: return nil
case .hosts: return .hosts
case .keychain: return .keychain
case .portForwarding: return .portForwarding
case .snippets: return .snippets
case .knownHosts: return .knownHosts
}
@@ -140,6 +140,7 @@ struct WorkspaceNavigatorView: View {
case .welcome: 0
case .hosts: app.hostLibrary.hosts.count
case .keychain: app.hostLibrary.keys.count + app.hostLibrary.identities.count
case .portForwarding: app.forwardings.rules.count
case .snippets: app.hostLibrary.snippets.count
case .knownHosts: app.knownHosts.entries.count
}
+11
View File
@@ -18,6 +18,7 @@ nonisolated enum WindowToolbarFindKind: String, Hashable, Sendable, CaseIterable
case metrics
case processes
case keychain
case portForwarding
case snippets
case knownHosts
case assistant
@@ -725,6 +726,12 @@ extension WindowModel {
},
importFolder,
]
case .portForwarding:
return [
WindowToolbarCreationAction(id: "forwarding", title: "New Forwarding", symbol: "plus") {
self.openVaultWork(.forwardingEditor(nil), on: .portForwarding)
}
]
case .knownHosts:
return [
WindowToolbarCreationAction(id: "known-host", title: "Add Host Key", symbol: "plus") {
@@ -796,6 +803,10 @@ extension WindowModel {
query: Binding(get: { state.query }, set: { state.query = $0 }),
focusRequest: findFocusRequest
)
case .portForwarding:
let state = vaultPages.portForwarding
return WindowToolbarFindContext(
query: Binding(get: { state.query }, set: { state.query = $0 }), focusRequest: findFocusRequest)
case .knownHosts:
let state = vaultPages.knownHosts
return WindowToolbarFindContext(
+20
View File
@@ -3,6 +3,26 @@ import Testing
@Suite
struct ArchitectureBoundaryTests {
@Test
func forwardingAndMobileAuthenticationRemainFeatureOwned() throws {
let controller = try String(
contentsOf: sourceRoot.appending(
path: "../Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift"),
encoding: .utf8)
for symbol in ["UserDefaults", "SettingsKey", "SessionReconnectController", "ExternalAuthentication"] {
#expect(!containsToken(symbol, in: controller))
}
for path in ["../TFI/App/MobileAppModel.swift", "../TFI/App/MobileRootView.swift"] {
let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8)
#expect(!containsToken("SSHKeyboardInteractiveQueue", in: source))
#expect(!containsToken("keyboardAuthentication", in: source))
}
for path in ["Features/HostEditor/HostEditorView.swift", "../TFI/Features/HostEditor/MobileHostEditor.swift"] {
let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8)
#expect(!containsToken("PortForwardingRule", in: source))
}
}
@Test
func mobileLayersKeepUIAndInfrastructureSeparate() throws {
try assertNoImports(
+1 -1
View File
@@ -360,7 +360,7 @@ struct ConnectionJourneyTests {
let store = HostLibraryModel(hosts: [host, shared], knownHosts: [entry])
host.host = "new.example"
host.port = 2222
#expect(store.upsert(host, replacingPortForwardings: []))
#expect(store.saveHost(host))
#expect(store.knownHosts == [entry])
#expect(store.knownHosts(for: host).isEmpty)
#expect(store.knownHosts(for: shared) == [entry])
+10 -36
View File
@@ -23,11 +23,10 @@ struct PortForwardingEditorTests {
PortForwardingEditorPolicy(
rule: candidate, savedRule: saved ? rule : nil,
savedHost: saved ? library.host(candidateHost.id) : nil,
isEnabled: false, isRuntimeActive: false)
isRuntimeActive: false)
}
#expect(state(rule, host).canStart)
#expect(state(rule, host).canEdit)
#expect(state(rule, host, saved: false).canToggle)
// A nil normalized draft can still use the matching saved configuration.
#expect(state(nil, host).canStart)
var changedRule = rule
@@ -40,50 +39,28 @@ struct PortForwardingEditorTests {
#expect(!state(rule, changedHost).canStart)
let unsavedRule = PortForwardingRule(hostID: host.id)
#expect(!state(unsavedRule, host).canStart)
#expect(state(unsavedRule, host).canToggle)
let deletedHost = PortForwardingEditorPolicy(
rule: rule, savedRule: rule, savedHost: nil, isEnabled: false,
rule: rule, savedRule: rule, savedHost: nil,
isRuntimeActive: PortForwardingRuntimeState.stopped.isActive)
#expect(!deletedHost.canStart)
}
@Test(arguments: [false, true])
func allActivePhasesAllowStoppingDirtyDraftsAndLockEditing(isEnabled: Bool) {
@Test
func allActivePhasesLockEditing() {
let phases: [PortForwardingRuntimeState] = [
.preparing, .awaitingTrust, .awaitingAuthentication, .connecting, .starting,
.running(activeConnections: 2), .reconnecting(attempt: 1, nextRetry: .now, message: "Retry"),
.running(activeConnections: 2),
]
for phase in phases {
let state = PortForwardingEditorPolicy(
rule: nil, savedRule: nil, savedHost: nil, isEnabled: isEnabled, isRuntimeActive: phase.isActive)
#expect(state.canToggle)
rule: nil, savedRule: nil, savedHost: nil, isRuntimeActive: phase.isActive)
#expect(!state.canStart)
#expect(!state.canEdit)
#expect(!state.isWaitingForSave)
#expect(state.isEnabled || state.isRuntimeActive)
}
let stopped = PortForwardingEditorPolicy(
rule: nil, savedRule: nil, savedHost: nil, isEnabled: false,
rule: nil, savedRule: nil, savedHost: nil,
isRuntimeActive: PortForwardingRuntimeState.stopped.isActive)
#expect(stopped.canEdit)
#expect(stopped.canToggle)
}
@Test func enabledUnsavedRulesWaitForSaveAndRemainEditable() {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let waiting = PortForwardingEditorPolicy(
rule: rule, savedRule: nil, savedHost: nil,
isEnabled: true, isRuntimeActive: PortForwardingRuntimeState.stopped.isActive)
#expect(waiting.isWaitingForSave)
#expect(waiting.canEdit)
#expect(waiting.canToggle)
let saved = PortForwardingEditorPolicy(
rule: rule, savedRule: rule, savedHost: host,
isEnabled: true, isRuntimeActive: PortForwardingRuntimeState.failed("Connection refused").isActive)
#expect(!saved.isWaitingForSave)
#expect(saved.canEdit)
}
@Test func failedSavedRulesCanBeEditedAndRetried() {
@@ -91,10 +68,9 @@ struct PortForwardingEditorTests {
let rule = PortForwardingRule(hostID: host.id)
let state = PortForwardingEditorPolicy(
rule: rule, savedRule: rule, savedHost: host,
isEnabled: false, isRuntimeActive: PortForwardingRuntimeState.failed("Connection refused").isActive)
isRuntimeActive: PortForwardingRuntimeState.failed("Connection refused").isActive)
#expect(state.canEdit)
#expect(state.canStart)
#expect(state.canToggle)
}
@Test(arguments: [PortForwardingRule.Kind.local, .remote])
@@ -156,17 +132,16 @@ struct PortForwardingEditorTests {
let library = HostLibraryModel()
let app = AppModel(hosts: library)
defer { app.shutdownForTermination() }
#expect(library.upsert(host, replacingPortForwardings: [rule]))
library.upsert(host)
#expect(library.upsert(rule))
// Reopening the host editor reloads persisted metadata, including its assigned sort index.
let savedHost = try #require(library.host(host.id))
let state = PortForwardingEditorPolicy(
rule: PortForwardingEditorPolicy.normalized(rule),
savedRule: app.forwardings.portForwarding(rule.id),
savedHost: app.hostLibrary.host(savedHost.id),
isEnabled: app.forwarding.isEnabled(rule.id),
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
#expect(state.canStart)
#expect(!app.forwarding.isEnabled(rule.id))
#expect(app.forwarding.state(for: rule.id) == .stopped)
var remotelyChanged = rule
remotelyChanged.bindPort = 9000
@@ -175,7 +150,6 @@ struct PortForwardingEditorTests {
rule: PortForwardingEditorPolicy.normalized(rule),
savedRule: app.forwardings.portForwarding(rule.id),
savedHost: app.hostLibrary.host(savedHost.id),
isEnabled: app.forwarding.isEnabled(rule.id),
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
#expect(reopened.canStart)
}
+7 -119
View File
@@ -15,99 +15,6 @@ import struct TermindDomain.Host
@Suite
@MainActor
struct PortForwardingFeatureTests {
@Test
func unsavedEnabledIntentWaitsForSaveAndCanBeDiscarded() throws {
let suite = "PortForwardingPendingIntentTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel()
let controller = PortForwardingController(hosts: library, defaults: defaults)
controller.setEnabled(true, for: rule.id)
#expect(controller.isEnabled(rule.id))
#expect(controller.state(for: rule.id) == .stopped)
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString])
controller.resumeEnabled([])
#expect(controller.isEnabled(rule.id))
controller.discardPending([rule.id])
#expect(!controller.isEnabled(rule.id))
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs)?.isEmpty == true)
}
@Test
func savingAPendingRuleMakesItRunnable() throws {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel()
let controller = PortForwardingController(hosts: library)
library.didReplacePortForwardings = { controller.resumeEnabled($0) }
controller.setEnabled(true, for: rule.id)
#expect(controller.state(for: rule.id) == .stopped)
#expect(library.upsert(host, replacingPortForwardings: [rule]))
#expect(controller.isEnabled(rule.id))
#expect(controller.state(for: rule.id) != .stopped)
controller.setEnabled(false, for: rule.id)
}
@Test
func enabledIntentPersistsLocallyAndPrunesMissingRulesWithoutStarting() throws {
let suite = "PortForwardingIntentTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let missingID = UUID()
let library = HostLibraryModel(hosts: [host], portForwardings: [rule])
defaults.set(
[rule.id.uuidString, missingID.uuidString, "invalid"],
forKey: SettingsKey.portForwardingEnabledRuleIDs)
let controller = PortForwardingController(hosts: library, defaults: defaults)
#expect(controller.isEnabled(rule.id))
#expect(controller.isEnabled(missingID))
controller.resumeEnabled([])
#expect(controller.isEnabled(rule.id))
#expect(!controller.isEnabled(missingID))
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString])
let restored = PortForwardingController(hosts: library, defaults: defaults)
#expect(restored.isEnabled(rule.id))
restored.suspend(rule.id)
#expect(restored.isEnabled(rule.id))
restored.remove([rule.id])
#expect(!restored.isEnabled(rule.id))
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs)?.isEmpty == true)
}
@Test
func failedAutomaticStartKeepsEnabledIntentUntilTheUserDisablesIt() async throws {
let suite = "PortForwardingFailureIntentTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel(hosts: [host], portForwardings: [rule])
defaults.set([rule.id.uuidString], forKey: SettingsKey.portForwardingEnabledRuleIDs)
let controller = PortForwardingController(hosts: library, defaults: defaults)
controller.resumeEnabled()
for _ in 0..<100 {
if case .failed = controller.state(for: rule.id) { break }
await Task.yield()
}
guard case .failed = controller.state(for: rule.id) else {
Issue.record("Expected the missing identity to fail forwarding startup")
return
}
#expect(controller.isEnabled(rule.id))
controller.setEnabled(false, for: rule.id)
#expect(!controller.isEnabled(rule.id))
}
@Test
func proxyIdentityRequiresUsernameAndSavedPassword() {
let eligible = Identity(
@@ -345,31 +252,12 @@ struct PortForwardingFeatureTests {
}
@Test
func hostAndForwardingDraftsCommitAsOneHostScopedCollection() {
let first = Host(name: "First", host: "first.example")
let second = Host(name: "Second", host: "second.example")
let oldFirstRule = PortForwardingRule(hostID: first.id, bindPort: 8000)
let secondRule = PortForwardingRule(hostID: second.id, bindPort: 9000)
let store = HostLibraryModel(
hosts: [first, second], portForwardings: [oldFirstRule, secondRule]
)
var renamed = first
renamed.name = "First Renamed"
let replacement = PortForwardingRule(
hostID: UUID(), kind: .remote, bindPort: 8080,
targetHost: "localhost", targetPort: 3000
)
#expect(store.upsert(renamed, replacingPortForwardings: [replacement]))
#expect(store.host(first.id)?.name == "First Renamed")
#expect(store.portForwarding(oldFirstRule.id) == nil)
#expect(store.portForwarding(replacement.id)?.hostID == first.id)
#expect(store.portForwarding(secondRule.id) == secondRule)
let invalid = PortForwardingRule(
hostID: first.id, bindPort: 0, targetHost: "", targetPort: 0
)
#expect(!store.upsert(renamed, replacingPortForwardings: [invalid]))
#expect(store.portForwarding(replacement.id) != nil)
func savingAHostPreservesItsIndependentForwardingRules() {
var host = Host(name: "First", host: "first.example")
let rule = PortForwardingRule(hostID: host.id)
let store = HostLibraryModel(hosts: [host], portForwardings: [rule])
host.name = "Renamed"
#expect(store.saveHost(host))
#expect(store.portForwardings == [rule])
}
}
+368
View File
@@ -0,0 +1,368 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite @MainActor
struct PortForwardingJourneyTests {
@Test func windowsShareForwardingAuthenticationAndDeletingItsJumpHostCancelsIt() async throws {
let jump = Host(name: "Jump", host: "jump.invalid")
let host = Host(name: "Server", host: "server.invalid", jumpHostID: jump.id)
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel(hosts: [jump, host], portForwardings: [rule])
let app = AppModel(hosts: library)
defer { app.shutdownForTermination() }
let first = WindowModel()
let second = WindowModel()
first.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
second.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
app.forwarding.start(rule.id)
try await wait { app.forwarding.connection(for: rule.id)?.authenticationPrompt != nil }
let context = try #require(app.forwarding.connection(for: rule.id))
first.closeVaultWork()
first.openPage(.snippets)
#expect(second.vaultWorkRoute == .forwardingEditor(rule.id))
#expect(context.authenticationPrompt != nil)
#expect(app.requestHostDeletion(jump.id))
#expect(context.authenticationPrompt == nil)
#expect(app.forwarding.state(for: rule.id) == .stopped)
#expect(app.forwarding.connection(for: rule.id) == nil)
}
@Test func savedRulesNeverStartUntilRequestedAndFailureNeverRetries() async throws {
let fixture = Fixture(mode: .failure)
let rule = fixture.rules[0]
#expect(fixture.sessions.isEmpty)
fixture.controller.start(rule.id)
try await wait {
if case .failed = fixture.controller.state(for: rule.id) { return true }
return false
}
#expect(fixture.sessions.count == 1)
fixture.controller.reconcileConfiguration()
#expect(fixture.sessions.count == 1)
#expect(fixture.controller.connection(for: rule.id)?.journey.purpose == .portForwarding)
fixture.controller.retry(rule.id)
try await wait { fixture.sessions.count == 2 }
fixture.controller.stopAll()
}
@Test func sharedHostAuthenticatesOnceAndStoppingOneRulePreservesItsPeer() async throws {
let fixture = Fixture(mode: .keyboard)
defer { fixture.controller.stopAll() }
let first = fixture.rules[0]
let second = fixture.rules[1]
fixture.controller.start(first.id)
fixture.controller.start(second.id)
try await wait {
fixture.controller.connection(for: first.id)?.connection.keyboardAuthentication.current != nil
}
let context = try #require(fixture.controller.connection(for: first.id))
#expect(context === fixture.controller.connection(for: second.id))
#expect(fixture.sessions.count == 1)
fixture.controller.stop(first.id)
#expect(context.connection.keyboardAuthentication.current != nil)
try await finishAuthentication(context)
try await wait { fixture.controller.state(for: second.id) == .running(activeConnections: 0) }
let session = try #require(fixture.sessions.first)
#expect(await session.starts == [second.id])
#expect(fixture.controller.state(for: first.id) == .stopped)
#expect(!context.journey.plainTextLog.contains("secret-response"))
#expect(!context.journey.plainTextLog.contains("private instruction"))
}
@Test func stoppingTheLastRuleCancelsAuthenticationAndRejectsLateRequestsAndCallbacks() async throws {
let fixture = Fixture(mode: .keyboard)
let rule = fixture.rules[0]
fixture.controller.start(rule.id)
try await wait { fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil }
let old = try #require(fixture.controller.connection(for: rule.id))
let challenge = try #require(old.connection.keyboardAuthentication.current)
let session = try #require(fixture.sessions.first)
let request = try #require(await session.requests.first)
fixture.controller.stop(rule.id)
#expect(old.connection.keyboardAuthentication.pending.isEmpty)
fixture.controller.start(rule.id)
try await wait {
fixture.sessions.count == 2
&& fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil
}
old.connection.keyboardAuthentication.respond(challenge.id, answers: ["stale"])
await session.fail()
if let responder = request.keyboardInteractive {
await #expect(throws: CancellationError.self) { try await responder(challenge) }
}
#expect(fixture.controller.state(for: rule.id) == .awaitingAuthentication)
#expect(old.connection.keyboardAuthentication.pending.isEmpty)
fixture.controller.stopAll()
}
@Test func hostTrustContinuesTheOriginalConnectionAndCanBeTemporary() async throws {
let fixture = Fixture(mode: .trust)
defer { fixture.controller.stopAll() }
let rule = fixture.rules[0]
fixture.controller.start(rule.id)
try await wait { fixture.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
let context = try #require(fixture.controller.connection(for: rule.id))
let prompt = try #require(context.connection.pendingHostKey)
fixture.controller.acceptHostKey(
for: rule.id, requestID: try #require(context.hostKeyRequestID),
observation: prompt.observation, remember: false)
try await wait { fixture.controller.state(for: rule.id) == .running(activeConnections: 0) }
#expect(fixture.sessions.count == 1)
#expect(await fixture.sessions[0].requests.count == 1)
#expect(fixture.library.knownHosts.isEmpty)
}
@Test func retiredTrustAndCredentialFormsCannotAuthorizeAReplacementConnection() async throws {
let trust = Fixture(mode: .trust)
defer { trust.controller.stopAll() }
let rule = trust.rules[0]
trust.controller.start(rule.id)
try await wait { trust.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
let old = try #require(trust.controller.connection(for: rule.id))
let oldID = try #require(old.hostKeyRequestID)
let observation = try #require(old.connection.pendingHostKey?.observation)
trust.controller.stop(rule.id)
trust.controller.start(rule.id)
try await wait { trust.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
let current = try #require(trust.controller.connection(for: rule.id))
trust.controller.acceptHostKey(for: rule.id, requestID: oldID, observation: observation, remember: true)
#expect(current.connection.pendingHostKey != nil)
#expect(trust.library.knownHosts.isEmpty)
trust.controller.acceptHostKey(
for: rule.id, requestID: try #require(current.hostKeyRequestID), observation: observation, remember: true)
try await wait { trust.controller.state(for: rule.id) == .running(activeConnections: 0) }
#expect(trust.library.knownHosts.count == 1)
let credentials = Fixture(mode: .immediate, hasIdentity: false)
defer { credentials.controller.stopAll() }
let credentialRule = credentials.rules[0]
credentials.controller.start(credentialRule.id)
try await wait { credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt != nil }
let oldPrompt = try #require(credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt)
credentials.controller.stop(credentialRule.id)
credentials.controller.start(credentialRule.id)
try await wait { credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt != nil }
credentials.controller.submitAuthentication(
for: credentialRule.id, promptID: oldPrompt.id, identityID: nil, username: "stale",
password: "stale", passphrase: nil, keyID: nil, save: true)
#expect(credentials.library.identities.isEmpty)
#expect(credentials.controller.state(for: credentialRule.id) == .awaitingAuthentication)
}
@Test func encryptedKeyPassphraseResumesPreparationInTheJourney() async throws {
let material = try SSHKeyMaterial.generate(.ed25519, comment: "forwarding", passphrase: "test-passphrase")
let key = SSHKeyRef(
label: "Encrypted", algorithm: .ed25519, publicKey: material.publicKey,
fingerprint: material.fingerprint, credentialRef: "test-key", storage: .thisDevice, hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid", identityID: identity.id)
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel(hosts: [host], identities: [identity], portForwardings: [rule])
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey)))
let controller = PortForwardingController(hosts: library) {
ForwardingTestSession(mode: .immediate, count: $0, failure: $1)
}
defer { controller.stopAll() }
controller.start(rule.id)
try await wait { controller.connection(for: rule.id)?.authenticationPrompt != nil }
let context = try #require(controller.connection(for: rule.id))
let prompt = try #require(context.authenticationPrompt)
#expect(prompt.requiresPassphrase)
controller.submitAuthentication(
for: rule.id, promptID: prompt.id, identityID: identity.id, username: "deploy",
password: nil, passphrase: "test-passphrase", keyID: key.id, save: false)
try await wait { controller.state(for: rule.id) == .running(activeConnections: 0) }
#expect(!context.journey.plainTextLog.contains("test-passphrase"))
}
@Test func savedCredentialsContinueTheExplicitOpeningWithoutStartingOtherRules() async throws {
let fixture = Fixture(mode: .immediate, hasIdentity: false)
defer { fixture.controller.stopAll() }
let rule = fixture.rules[0]
fixture.controller.start(rule.id)
try await wait { fixture.controller.connection(for: rule.id)?.authenticationPrompt != nil }
let context = try #require(fixture.controller.connection(for: rule.id))
let prompt = try #require(context.authenticationPrompt)
#expect(context.journey.waitsForLocalIdentity)
fixture.controller.submitAuthentication(
for: rule.id, promptID: prompt.id, identityID: nil,
username: "deploy", password: "test-password", passphrase: nil, keyID: nil, save: true)
try await wait { fixture.controller.state(for: rule.id) == .running(activeConnections: 0) }
#expect(
fixture.library.identity(for: try #require(fixture.library.host(fixture.host.id)))?.username == "deploy")
#expect(fixture.controller.state(for: fixture.rules[1].id) == .stopped)
#expect(!context.journey.plainTextLog.contains("test-password"))
}
@Test func jumpChallengesStayInTheForwardingJourneyAndConfigurationChangesStopIt() async throws {
let fixture = Fixture(mode: .keyboard, usesJump: true)
let rule = fixture.rules[0]
fixture.controller.start(rule.id)
try await wait { fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil }
let context = try #require(fixture.controller.connection(for: rule.id))
let challenge = try #require(context.connection.keyboardAuthentication.current)
#expect(challenge.hostID == fixture.host.jumpHostID)
#expect(context.journey.checkpoint?.endpointIndex == 1)
var jump = try #require(fixture.library.host(challenge.hostID))
jump.port = 2222
fixture.library.upsert(jump)
#expect(fixture.controller.state(for: rule.id) == .stopped)
#expect(context.connection.keyboardAuthentication.pending.isEmpty)
#expect(fixture.sessions.count == 1)
}
@Test func listenerFailureDoesNotDisconnectAnExistingRuleAndDisconnectNeverRestarts() async throws {
let fixture = Fixture(mode: .listenerFailure)
defer { fixture.controller.stopAll() }
let first = fixture.rules[0]
let second = fixture.rules[1]
fixture.controller.start(first.id)
try await wait { fixture.controller.state(for: first.id) == .running(activeConnections: 0) }
fixture.controller.start(second.id)
try await wait {
if case .failed = fixture.controller.state(for: second.id) { return true }
return false
}
#expect(fixture.controller.state(for: first.id).isActive)
#expect(fixture.sessions.count == 1)
let session = try #require(fixture.sessions.first)
#expect(await session.disconnects == 0)
await session.fail()
try await wait {
if case .failed = fixture.controller.state(for: first.id) { return true }
return false
}
fixture.controller.reconcileConfiguration()
#expect(fixture.sessions.count == 1)
}
@Test func rapidStopAndStartCannotLeaveTheNewListenerStopped() async throws {
let fixture = Fixture(mode: .immediate)
defer { fixture.controller.stopAll() }
let first = fixture.rules[0]
let second = fixture.rules[1]
fixture.controller.start(first.id)
fixture.controller.start(second.id)
try await wait {
fixture.rules.allSatisfy { fixture.controller.state(for: $0.id) == .running(activeConnections: 0) }
}
fixture.controller.stop(first.id)
fixture.controller.start(first.id)
try await wait { fixture.controller.state(for: first.id) == .running(activeConnections: 0) }
#expect(await fixture.sessions[0].listening == Set(fixture.rules.map(\.id)))
#expect(fixture.sessions.count == 1)
}
private func finishAuthentication(_ context: PortForwardingConnection) async throws {
let first = try #require(context.connection.keyboardAuthentication.current)
context.connection.keyboardAuthentication.respond(first.id, answers: ["secret-response"])
try await wait { context.connection.keyboardAuthentication.current?.round == 2 }
let second = try #require(context.connection.keyboardAuthentication.current)
#expect(first.connectionID == second.connectionID && first.id != second.id)
context.connection.keyboardAuthentication.respond(first.id, answers: ["stale"])
#expect(context.connection.keyboardAuthentication.current?.id == second.id)
context.connection.keyboardAuthentication.respond(second.id, answers: [""])
}
private func wait(_ predicate: () -> Bool) async throws {
for _ in 0..<2000 {
if predicate() { return }
try await Task.sleep(for: .milliseconds(1))
}
throw CocoaError(.coderInvalidValue)
}
@MainActor
private final class Fixture {
let host: Host
let rules: [PortForwardingRule]
let library: HostLibraryModel
var sessions: [ForwardingTestSession] = []
let mode: ForwardingTestSession.Mode
lazy var controller = PortForwardingController(hosts: library) { [unowned self] count, failure in
let session = ForwardingTestSession(mode: mode, count: count, failure: failure)
sessions.append(session)
return session
}
init(mode: ForwardingTestSession.Mode, hasIdentity: Bool = true, usesJump: Bool = false) {
self.mode = mode
let identity = Identity(name: "Deploy", username: "deploy")
let jump = Host(name: "Jump", host: "jump.invalid", identityID: identity.id)
host = Host(
name: "Server", host: "server.invalid", identityID: hasIdentity ? identity.id : nil,
jumpHostID: usesJump ? jump.id : nil)
rules = [
PortForwardingRule(hostID: host.id, bindPort: 8000),
PortForwardingRule(hostID: host.id, bindPort: 9000),
]
library = HostLibraryModel(
hosts: usesJump ? [jump, host] : [host], identities: hasIdentity ? [identity] : [],
portForwardings: rules)
library.willDeletePortForwardings = { [weak self] in self?.controller.stop($0) }
library.didChangeHostConnection = { [weak self] in self?.controller.hostConfigurationChanged($0) }
}
}
}
private actor ForwardingTestSession: SSHForwardingSessionProtocol {
enum Mode: Sendable { case immediate, keyboard, trust, failure, listenerFailure }
let mode: Mode
let count: SSHForwardingSession.ConnectionHandler
let failure: SSHForwardingSession.FailureHandler
var requests: [SSHConnectionRequest] = []
var starts: [UUID] = []
var listening: Set<UUID> = []
var disconnects = 0
init(
mode: Mode, count: @escaping SSHForwardingSession.ConnectionHandler,
failure: @escaping SSHForwardingSession.FailureHandler
) {
self.mode = mode
self.count = count
self.failure = failure
}
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws {
requests.append(request)
let configuration = request.configuration
if mode == .failure { throw TermindSSHError.disconnected }
if mode == .trust {
let observation = SSHHostKeyObservation(
hostname: configuration.host, port: configuration.port,
algorithm: .ed25519, fingerprint: "SHA256:test")
let decision = try await request.authorizeHostKey?(
SSHHostKeyChallenge(hostID: configuration.hostID, observation: observation, expectedHostKeys: []))
guard decision == .accept else { throw CancellationError() }
}
if mode == .keyboard {
let connectionID = UUID()
for round in 1...2 {
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: connectionID,
hostID: configuration.jumps.first?.id ?? configuration.hostID,
hostname: configuration.host, port: 22, username: configuration.username, round: round,
name: "PAM", instruction: "private instruction", prompts: [.init(text: "Code", echo: false)])
_ = try await request.keyboardInteractive?(challenge)
}
}
try Task.checkCancellation()
progress?(.connected(configuration.hostID))
}
func start(_ rule: PortForwardingRule) async throws {
if mode == .listenerFailure && rule.bindPort == 9000 { throw TermindSSHError.route("Port in use") }
starts.append(rule.id)
listening.insert(rule.id)
}
func stop(_ id: UUID) async { listening.remove(id) }
func disconnect() async {
disconnects += 1
listening.removeAll()
}
func fail() { failure("Connection lost") }
}
+112
View File
@@ -0,0 +1,112 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized) @MainActor
struct PortForwardingRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func cardsAndJourneyKeepTheirStateAcrossWindowSizes(_ language: String) async throws {
let host = Host(name: "Production", host: "server.example.invalid")
let rules = [
PortForwardingRule(hostID: host.id, label: "Database", colorTag: .teal),
PortForwardingRule(hostID: host.id, kind: .remote, bindPort: 9090, label: "Preview", colorTag: .orange),
PortForwardingRule(hostID: host.id, kind: .dynamic, bindPort: 1080, label: "SOCKS5"),
PortForwardingRule(hostID: host.id, bindPort: 15432),
]
let library = HostLibraryModel(hosts: [host], portForwardings: rules)
let app = AppModel(hosts: library)
let navigation = WindowModel()
navigation.openPage(.portForwarding)
let controller = NSHostingController(
rootView: VaultWorkspaceView().environment(app).environment(navigation)
.environment(\.locale, Locale(identifier: language)))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 960, height: 740),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
controller.sizingOptions = []
window.contentViewController = controller
window.setContentSize(NSSize(width: 960, height: 740))
controller.view.frame = NSRect(x: 0, y: 0, width: 960, height: 740)
window.makeKeyAndOrderFront(nil)
defer {
app.shutdownForTermination()
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
try await Task.sleep(for: .milliseconds(300))
controller.view.layoutSubtreeIfNeeded()
try capture(controller.view, name: "macos-forwarding-cards-\(language)")
let marquee = try #require(
descendants(in: controller.view).first { String(describing: type(of: $0)) == "MarqueeInputView" })
marquee.mouseDown(with: try mouseEvent(.leftMouseDown, at: CGPoint(x: 2, y: 2), in: marquee))
marquee.mouseDragged(
with: try mouseEvent(
.leftMouseDragged, at: CGPoint(x: marquee.bounds.maxX - 2, y: marquee.bounds.maxY - 2), in: marquee))
marquee.mouseUp(with: try mouseEvent(.leftMouseUp, at: CGPoint(x: 2, y: 2), in: marquee))
#expect(Set(navigation.vaultSelection(for: .portForwarding).compactMap(\.forwardingID)) == Set(rules.map(\.id)))
try await Task.sleep(for: .milliseconds(100))
try capture(controller.view, name: "macos-forwarding-selected-\(language)")
marquee.mouseDown(with: try mouseEvent(.leftMouseDown, at: CGPoint(x: 2, y: 2), in: marquee))
marquee.mouseUp(with: try mouseEvent(.leftMouseUp, at: CGPoint(x: 2, y: 2), in: marquee))
#expect(navigation.vaultSelection(for: .portForwarding).isEmpty)
navigation.openVaultWork(.forwardingEditor(rules[0].id), on: .portForwarding)
try await Task.sleep(for: .milliseconds(300))
try capture(controller.view, name: "macos-forwarding-editor-\(language)")
navigation.closeVaultWork()
app.forwarding.start(rules[0].id)
for _ in 0..<400 where app.forwarding.connection(for: rules[0].id)?.authenticationPrompt == nil {
try await Task.sleep(for: .milliseconds(5))
}
let context = try #require(app.forwarding.connection(for: rules[0].id))
let prompt = try #require(context.authenticationPrompt)
navigation.openVaultWork(.forwardingEditor(rules[0].id), on: .portForwarding)
for width in [420.0, 960.0] {
window.setContentSize(NSSize(width: width, height: 740))
controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 740)
try await Task.sleep(for: .milliseconds(300))
controller.view.layoutSubtreeIfNeeded()
#expect(context.authenticationPrompt?.id == prompt.id)
#expect(window.attachedSheet == nil)
#expect(app.externalAuthentication.pending.isEmpty)
try capture(controller.view, name: "macos-forwarding-journey-\(language)-\(Int(width))")
}
navigation.closeVaultWork()
navigation.openPage(.snippets)
#expect(context.authenticationPrompt?.id == prompt.id)
navigation.openVaultWork(.forwardingEditor(rules[0].id), on: .portForwarding)
#expect(app.forwarding.connection(for: rules[0].id) === context)
app.forwarding.stop(rules[0].id)
#expect(context.authenticationPrompt == nil)
#expect(app.forwarding.state(for: rules[0].id) == .stopped)
}
private func descendants(in view: NSView) -> [NSView] {
[view] + view.subviews.flatMap { descendants(in: $0) }
}
private func mouseEvent(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws -> NSEvent {
try #require(
NSEvent.mouseEvent(
with: type, location: view.convert(point, to: nil), modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: view.window?.windowNumber ?? 0,
context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
}
private func capture(_ view: NSView, name: String) throws {
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "\(name).png")
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-forwarding-ui")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: "\(name).png"))
}
}
+18
View File
@@ -14,6 +14,24 @@ import struct TermindDomain.Host
@Suite
@MainActor
struct VaultSelectionTests {
@Test func forwardingLivesBetweenKeychainAndSnippetsWithIndependentSelectionAndSearch() {
let pages = VaultPage.allCases
#expect(pages.firstIndex(of: .portForwarding) == pages.firstIndex(of: .keychain).map { $0 + 1 })
#expect(pages.firstIndex(of: .snippets) == pages.firstIndex(of: .portForwarding).map { $0 + 1 })
let window = WindowModel()
let id = UUID()
window.selectVaultItem(.forwarding(id), on: .portForwarding)
window.vaultPages.portForwarding.query = "Database"
window.openPage(.keychain)
window.openPage(.portForwarding)
#expect(window.findKind == .portForwarding)
#expect(window.findContext()?.query.wrappedValue == "Database")
#expect(window.vaultSelection(for: .portForwarding) == [.forwarding(id)])
#expect(window.creationActions(for: .portForwarding).count == 1)
window.pruneVaultSelections(in: HostLibraryModel())
#expect(window.vaultSelection(for: .portForwarding).isEmpty)
}
@Test
func selectionsAreOrderedDeduplicatedAndScopedPerPage() {
let window = WindowModel()