From 61cde4cd409a54cbd859d54541cfdb98eea19907 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 23:37:57 +0800 Subject: [PATCH] Refactor Port Forwarding --- FrameworkTests/ForwardingColorTests.swift | 97 +++ FrameworkTests/ForwardingLibraryTests.swift | 94 +++ .../PersistenceCompatibilityTests.swift | 2 +- .../PortForwardingEditorPolicyTests.swift | 31 +- Frameworks/Resources/Localizable.xcstrings | 203 +++++- .../Connections/ConnectionJourney.swift | 2 + .../ForwardingLibraryModel.swift | 25 +- .../FeatureModels/HostLibraryModel.swift | 128 +--- .../PortForwardingEditorPolicy.swift | 8 +- .../SSHConnectionTypes.swift | 9 - .../TermindApplication/SettingsKey.swift | 2 - .../Connections/PortForwardingRule.swift | 25 +- Frameworks/TermindDomain/TagColor.swift | 2 +- .../TermindPersistence/HostPersistence.swift | 19 +- .../TermindPersistence.swift | 2 +- .../TermindSchemaV19ForwardingModels.swift | 29 + .../TermindPresentation/ColorTagPicker.swift | 2 +- .../ConnectionJourneyRouteView.swift | 1 + .../ConnectionJourneySections.swift | 4 +- .../PortForwardingFields.swift | 10 + .../PortForwardingHostPicker.swift | 34 + .../PortForwardingRule+Presentation.swift | 44 ++ .../SSH/PortForwardingController.swift | 615 ++++++++++-------- .../SSH/SSHForwardingSession.swift | 18 +- Memory/ARCHITECTURE.md | 44 +- TFI/App/MobileAppDependencies.swift | 4 - TFI/App/MobileAppModel.swift | 40 +- TFI/App/MobileAppReviewPresenter.swift | 1 - TFI/App/MobileRootView.swift | 17 - .../MobileWorkspaceConnectionServices.swift | 12 - TFI/Design/MobileDesignTokens.swift | 2 +- .../HostEditor/MobileHostEditor.swift | 92 +-- .../Ports/MobileForwardingEditor.swift | 67 +- TFI/Features/Ports/MobileForwardingView.swift | 227 +++++++ TFI/Features/Vault/MobileVaultView.swift | 32 +- TFITests/MobileCardTests.swift | 28 + ...MobileCommandGeneratorAndUploadTests.swift | 5 +- TFITests/MobileForwardingLifecycleTests.swift | 89 +++ TFITests/MobileForwardingRenderingTests.swift | 136 ++++ TFITests/MobileIconRenderingTests.swift | 6 +- TFITests/MobileMetricsJourneyTests.swift | 3 - TFITests/MobileVaultFeatureTests.swift | 57 +- TFM/App/AppDependencies.swift | 3 - TFM/App/AppModel.swift | 22 +- TFM/App/TermindApp.swift | 7 +- .../Connection+Presentation.swift | 18 - TFM/Features/HostEditor/HostEditorView.swift | 33 +- TFM/Features/Ports/PortForwardingView.swift | 503 +++++++------- TFM/Features/Shell/WindowRoute.swift | 8 + TFM/Features/Vault/VaultPageStates.swift | 8 + TFM/Features/Vault/VaultView.swift | 3 + TFM/Features/Workspaces/WindowModel.swift | 2 + .../Workspaces/WorkspaceNavigatorView.swift | 1 + TFM/UI/Components/WindowToolbar.swift | 11 + TFMTests/ArchitectureBoundaryTests.swift | 20 + TFMTests/ConnectionJourneyTests.swift | 2 +- TFMTests/PortForwardingEditorTests.swift | 46 +- TFMTests/PortForwardingFeatureTests.swift | 126 +--- TFMTests/PortForwardingJourneyTests.swift | 368 +++++++++++ TFMTests/PortForwardingRenderingTests.swift | 112 ++++ TFMTests/VaultSelectionTests.swift | 18 + 61 files changed, 2381 insertions(+), 1198 deletions(-) create mode 100644 FrameworkTests/ForwardingColorTests.swift create mode 100644 FrameworkTests/ForwardingLibraryTests.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV19ForwardingModels.swift create mode 100644 Frameworks/TermindPresentation/PortForwardingHostPicker.swift create mode 100644 Frameworks/TermindPresentation/PortForwardingRule+Presentation.swift create mode 100644 TFI/Features/Ports/MobileForwardingView.swift create mode 100644 TFITests/MobileForwardingLifecycleTests.swift create mode 100644 TFITests/MobileForwardingRenderingTests.swift create mode 100644 TFMTests/PortForwardingJourneyTests.swift create mode 100644 TFMTests/PortForwardingRenderingTests.swift diff --git a/FrameworkTests/ForwardingColorTests.swift b/FrameworkTests/ForwardingColorTests.swift new file mode 100644 index 00000000..d08ba87b --- /dev/null +++ b/FrameworkTests/ForwardingColorTests.swift @@ -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) + } +} diff --git a/FrameworkTests/ForwardingLibraryTests.swift b/FrameworkTests/ForwardingLibraryTests.swift new file mode 100644 index 00000000..69ad1c74 --- /dev/null +++ b/FrameworkTests/ForwardingLibraryTests.swift @@ -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: []) + } +} diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index a81fc9db..3cd13d6f 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -65,7 +65,7 @@ struct PersistenceCompatibilityTests { try first.mainContext.save() #expect(try second.mainContext.fetchCount(FetchDescriptor()) == 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" }) } diff --git a/FrameworkTests/PortForwardingEditorPolicyTests.swift b/FrameworkTests/PortForwardingEditorPolicyTests.swift index 9db74f80..99a9944b 100644 --- a/FrameworkTests/PortForwardingEditorPolicyTests.swift +++ b/FrameworkTests/PortForwardingEditorPolicyTests.swift @@ -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) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 97046931..fd479d47 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index 0ba1164d..557a9b73 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -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" } } } diff --git a/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift index 9625db4b..7d637b51 100644 --- a/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift @@ -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) 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) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 0cc6f315..c9791d6e 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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) -> 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 diff --git a/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift index ba7901b7..6116e97a 100644 --- a/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift +++ b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift @@ -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 } diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index c3bf4654..ac8741ce 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -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 - } - } } diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index ed721b03..6303a81e 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -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" diff --git a/Frameworks/TermindDomain/Connections/PortForwardingRule.swift b/Frameworks/TermindDomain/Connections/PortForwardingRule.swift index 7b9892f4..f6b64e1a 100644 --- a/Frameworks/TermindDomain/Connections/PortForwardingRule.swift +++ b/Frameworks/TermindDomain/Connections/PortForwardingRule.swift @@ -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)" } } diff --git a/Frameworks/TermindDomain/TagColor.swift b/Frameworks/TermindDomain/TagColor.swift index 7a433000..9dc56008 100644 --- a/Frameworks/TermindDomain/TagColor.swift +++ b/Frameworks/TermindDomain/TagColor.swift @@ -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 { diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 86bce800..8b3225ab 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 4ecee767..7e44699c 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/Frameworks/TermindPersistence/TermindSchemaV19ForwardingModels.swift b/Frameworks/TermindPersistence/TermindSchemaV19ForwardingModels.swift new file mode 100644 index 00000000..c526db9b --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV19ForwardingModels.swift @@ -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 + } + } +} diff --git a/Frameworks/TermindPresentation/ColorTagPicker.swift b/Frameworks/TermindPresentation/ColorTagPicker.swift index 1832773a..4cfe3ae4 100644 --- a/Frameworks/TermindPresentation/ColorTagPicker.swift +++ b/Frameworks/TermindPresentation/ColorTagPicker.swift @@ -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 diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift index ef6f68bc..07e546c8 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -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" } } diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 3f4fad8c..4b0fd9d3 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -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: "" diff --git a/Frameworks/TermindPresentation/PortForwardingFields.swift b/Frameworks/TermindPresentation/PortForwardingFields.swift index 336589e2..aa87a5b7 100644 --- a/Frameworks/TermindPresentation/PortForwardingFields.swift +++ b/Frameworks/TermindPresentation/PortForwardingFields.swift @@ -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") { diff --git a/Frameworks/TermindPresentation/PortForwardingHostPicker.swift b/Frameworks/TermindPresentation/PortForwardingHostPicker.swift new file mode 100644 index 00000000..6f7793b4 --- /dev/null +++ b/Frameworks/TermindPresentation/PortForwardingHostPicker.swift @@ -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, 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 { + Binding( + get: { hosts.contains { $0.id == rule.hostID } ? rule.hostID : nil }, + set: { rule.hostID = $0 ?? UUID() }) + } +} diff --git a/Frameworks/TermindPresentation/PortForwardingRule+Presentation.swift b/Frameworks/TermindPresentation/PortForwardingRule+Presentation.swift new file mode 100644 index 00000000..b965a34b --- /dev/null +++ b/Frameworks/TermindPresentation/PortForwardingRule+Presentation.swift @@ -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" + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 930347a2..2f4b1e03 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -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? + 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 - /// 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 = [] - private var desired: Set = [] - private var sessions: [Host.ID: SSHForwardingSession] = [:] - private var connectionTasks: [Host.ID: Task] = [:] - private var reconnectTasks: [Host.ID: Task] = [:] - 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] = [:] - 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] = [:] + 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 = [] - 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 } } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift index 031d23d6..2c3120e3 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift @@ -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 diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index b2cd6b70..c5a8c1c1 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index 99235476..e499c4d8 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -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, diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 09aebc4c..a691c4ee 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) } diff --git a/TFI/App/MobileAppReviewPresenter.swift b/TFI/App/MobileAppReviewPresenter.swift index 130d9a53..8c958b4b 100644 --- a/TFI/App/MobileAppReviewPresenter.swift +++ b/TFI/App/MobileAppReviewPresenter.swift @@ -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() } diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index d3cce6ec..e07ca6b3 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -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) diff --git a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift index 1e2680e3..d4eb4d51 100644 --- a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift +++ b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift @@ -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 - } } diff --git a/TFI/Design/MobileDesignTokens.swift b/TFI/Design/MobileDesignTokens.swift index 8f120a17..ec68e649 100644 --- a/TFI/Design/MobileDesignTokens.swift +++ b/TFI/Design/MobileDesignTokens.swift @@ -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 { diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index ac14753c..3aa55e6d 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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 { @@ -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( diff --git a/TFI/Features/Ports/MobileForwardingEditor.swift b/TFI/Features/Ports/MobileForwardingEditor.swift index 366c16a4..62a2beb0 100644 --- a/TFI/Features/Ports/MobileForwardingEditor.swift +++ b/TFI/Features/Ports/MobileForwardingEditor.swift @@ -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 { - 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]) } - } } diff --git a/TFI/Features/Ports/MobileForwardingView.swift b/TFI/Features/Ports/MobileForwardingView.swift new file mode 100644 index 00000000..8ea7d31e --- /dev/null +++ b/TFI/Features/Ports/MobileForwardingView.swift @@ -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() + } +} diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 93255f70..02e348c5 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -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 diff --git a/TFITests/MobileCardTests.swift b/TFITests/MobileCardTests.swift index 7a53969d..1e335d9a 100644 --- a/TFITests/MobileCardTests.swift +++ b/TFITests/MobileCardTests.swift @@ -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") diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 0feae604..620fc76e 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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) } diff --git a/TFITests/MobileForwardingLifecycleTests.swift b/TFITests/MobileForwardingLifecycleTests.swift new file mode 100644 index 00000000..60c97614 --- /dev/null +++ b/TFITests/MobileForwardingLifecycleTests.swift @@ -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 } +} diff --git a/TFITests/MobileForwardingRenderingTests.swift b/TFITests/MobileForwardingRenderingTests.swift new file mode 100644 index 00000000..2f6df162 --- /dev/null +++ b/TFITests/MobileForwardingRenderingTests.swift @@ -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 +} diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 28dde411..8c2fe9b3 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -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) } diff --git a/TFITests/MobileMetricsJourneyTests.swift b/TFITests/MobileMetricsJourneyTests.swift index d532abb5..a24664b8 100644 --- a/TFITests/MobileMetricsJourneyTests.swift +++ b/TFITests/MobileMetricsJourneyTests.swift @@ -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) diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 35979ef8..9e2d22da 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -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 diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index 25528b07..5f2b2da9 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -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 } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 7e33fa14..ed0b1828 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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() diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index f069f265..01c2ca9f 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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)? diff --git a/TFM/DesignSystem/Connection+Presentation.swift b/TFM/DesignSystem/Connection+Presentation.swift index 31551fcf..038ec25b 100644 --- a/TFM/DesignSystem/Connection+Presentation.swift +++ b/TFM/DesignSystem/Connection+Presentation.swift @@ -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" - } - } -} diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 339a259b..0f12af35 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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.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() } diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index 402ea30f..64546158 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -4,320 +4,279 @@ import TermindDomain import TermindPresentation import TermindRemoteInfrastructure -import struct TermindDomain.Host +@Observable +final class PortForwardingPageState { + var query = "" + var deleting: Set = [] + 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] { + [ + ManagedCardSection( + id: "forwardings", title: String(localized: "Port Forwarding"), + items: app.forwardings.search(state.query), canAcceptDrop: false, showsWhenEmpty: false) + ] } + var managedSelection: ManagedCardSelection { + 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 { + 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, isDropTarget: Bool) -> some View { + CardSectionHeader(title: section.title, count: section.items.count) + } + func managedCard(for item: Item, context: ManagedCardItemContext) -> 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 { - 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 { - 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 } } } diff --git a/TFM/Features/Shell/WindowRoute.swift b/TFM/Features/Shell/WindowRoute.swift index b2eb8882..15804151 100644 --- a/TFM/Features/Shell/WindowRoute.swift +++ b/TFM/Features/Shell/WindowRoute.swift @@ -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 diff --git a/TFM/Features/Vault/VaultPageStates.swift b/TFM/Features/Vault/VaultPageStates.swift index 8c7a3652..965b8831 100644 --- a/TFM/Features/Vault/VaultPageStates.swift +++ b/TFM/Features/Vault/VaultPageStates.swift @@ -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 } diff --git a/TFM/Features/Vault/VaultView.swift b/TFM/Features/Vault/VaultView.swift index 2acc6956..1eea996c 100644 --- a/TFM/Features/Vault/VaultView.swift +++ b/TFM/Features/Vault/VaultView.swift @@ -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), diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 29d11d49..ab2b73fe 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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 } diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index e5da1458..ee96d465 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -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 } diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index 4a59013b..47fc68af 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -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( diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index ed43380e..2474304f 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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( diff --git a/TFMTests/ConnectionJourneyTests.swift b/TFMTests/ConnectionJourneyTests.swift index 2043d950..2f97c080 100644 --- a/TFMTests/ConnectionJourneyTests.swift +++ b/TFMTests/ConnectionJourneyTests.swift @@ -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]) diff --git a/TFMTests/PortForwardingEditorTests.swift b/TFMTests/PortForwardingEditorTests.swift index 930f2a5a..e4562ed5 100644 --- a/TFMTests/PortForwardingEditorTests.swift +++ b/TFMTests/PortForwardingEditorTests.swift @@ -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) } diff --git a/TFMTests/PortForwardingFeatureTests.swift b/TFMTests/PortForwardingFeatureTests.swift index 6aa1eba7..4f5e119d 100644 --- a/TFMTests/PortForwardingFeatureTests.swift +++ b/TFMTests/PortForwardingFeatureTests.swift @@ -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]) } } diff --git a/TFMTests/PortForwardingJourneyTests.swift b/TFMTests/PortForwardingJourneyTests.swift new file mode 100644 index 00000000..8a51d066 --- /dev/null +++ b/TFMTests/PortForwardingJourneyTests.swift @@ -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 = [] + 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") } +} diff --git a/TFMTests/PortForwardingRenderingTests.swift b/TFMTests/PortForwardingRenderingTests.swift new file mode 100644 index 00000000..c0a90dd3 --- /dev/null +++ b/TFMTests/PortForwardingRenderingTests.swift @@ -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")) + } +} diff --git a/TFMTests/VaultSelectionTests.swift b/TFMTests/VaultSelectionTests.swift index d7d79382..613fafd7 100644 --- a/TFMTests/VaultSelectionTests.swift +++ b/TFMTests/VaultSelectionTests.swift @@ -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()