Refactor Port Forwarding
This commit is contained in:
1 parent
32aed0a610
commit
61cde4cd40
61 files changed
+2381
-1198
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPersistence
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct ForwardingColorTests {
|
||||
@Test func legacyRulesDecodeWithoutColorAndNewRulesRoundTripEveryColor() throws {
|
||||
var rule = PortForwardingRule(hostID: UUID(), label: "Database")
|
||||
var legacy = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(rule)) as? [String: Any])
|
||||
legacy.removeValue(forKey: "colorTag")
|
||||
#expect(
|
||||
try JSONDecoder().decode(PortForwardingRule.self, from: JSONSerialization.data(withJSONObject: legacy))
|
||||
== rule)
|
||||
legacy["colorTag"] = "future-color"
|
||||
#expect(
|
||||
try JSONDecoder().decode(PortForwardingRule.self, from: JSONSerialization.data(withJSONObject: legacy))
|
||||
== rule)
|
||||
for color in TagColor.allCases {
|
||||
rule.colorTag = color
|
||||
#expect(try JSONDecoder().decode(PortForwardingRule.self, from: JSONEncoder().encode(rule)) == rule)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func colorEditsRemainDraftsUntilSavedAndSyncCanSetAndClearThem() throws {
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
let persistence = HostPersistence(context: container.mainContext)
|
||||
let library = HostLibraryModel(persistence: persistence)
|
||||
let model = ForwardingLibraryModel(library: library)
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
library.upsert(host)
|
||||
let original = PortForwardingRule(hostID: host.id, colorTag: .teal)
|
||||
try model.save(original)
|
||||
var draft = original
|
||||
draft.colorTag = .pink
|
||||
#expect(model.portForwarding(original.id)?.colorTag == .teal)
|
||||
try model.save(draft)
|
||||
#expect(try persistence.load().portForwardings == [draft])
|
||||
|
||||
let remoteContainer = try TermindPersistence.makeContainer(inMemory: true)
|
||||
let remote = HostPersistence(context: remoteContainer.mainContext)
|
||||
let hostPayload = VaultRecordPayload.host(host)
|
||||
_ = try remote.applyRemote([
|
||||
VaultEnvelope(key: hostPayload.key, version: .baseline(writerID: UUID()), payload: hostPayload)
|
||||
])
|
||||
for (index, color) in [TagColor.pink, .none].enumerated() {
|
||||
draft.colorTag = color
|
||||
let payload = VaultRecordPayload.portForwarding(draft)
|
||||
let envelope = VaultEnvelope(
|
||||
key: payload.key,
|
||||
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: Double(index + 1))),
|
||||
payload: payload)
|
||||
let received = try JSONDecoder().decode(VaultEnvelope.self, from: JSONEncoder().encode(envelope))
|
||||
_ = try remote.applyRemote([received])
|
||||
#expect(try remote.load().portForwardings == [draft])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func version19RulesMigrateWithoutChangingIDsHostsOrParameters() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("Termind.store")
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let rules = PortForwardingRule.Kind.allCases.map {
|
||||
PortForwardingRule(hostID: host.id, kind: $0, bindHost: "::1", bindPort: 15432, label: "Database")
|
||||
}
|
||||
do {
|
||||
let schema = Schema(versionedSchema: TermindSchemaV19.self)
|
||||
let legacy = try ModelContainer(
|
||||
for: schema,
|
||||
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
|
||||
legacy.mainContext.insert(PersistentHost(host))
|
||||
for rule in rules {
|
||||
legacy.mainContext.insert(TermindSchemaV19ForwardingModels.PersistentPortForwardingRule(rule))
|
||||
}
|
||||
try legacy.mainContext.save()
|
||||
}
|
||||
let container = try TermindPersistence.makeContainer(url: url)
|
||||
let persistence = HostPersistence(context: container.mainContext)
|
||||
let snapshot = try persistence.load()
|
||||
#expect(snapshot.hosts == [host])
|
||||
#expect(Set(snapshot.portForwardings) == Set(rules))
|
||||
let model = ForwardingLibraryModel(library: HostLibraryModel(persistence: persistence))
|
||||
var colored = rules[0]
|
||||
colored.colorTag = .purple
|
||||
try model.save(colored)
|
||||
let reopened = try TermindPersistence.makeContainer(url: url)
|
||||
#expect(
|
||||
try HostPersistence(context: reopened.mainContext).load().portForwardings.first { $0.id == colored.id }
|
||||
== colored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct ForwardingLibraryTests {
|
||||
@Test func rulesSaveRebindAndDeleteWithoutRewritingHosts() throws {
|
||||
let first = Host(name: "First", host: "first.invalid")
|
||||
let second = Host(name: "Second", host: "second.invalid")
|
||||
let store = HostLibraryModel(hosts: [first, second])
|
||||
let model = ForwardingLibraryModel(library: store)
|
||||
var rule = PortForwardingRule(hostID: first.id, label: "Database")
|
||||
try model.save(rule)
|
||||
let savedHosts = store.hosts
|
||||
rule.hostID = second.id
|
||||
try model.save(rule)
|
||||
#expect(model.rules == [rule])
|
||||
#expect(store.hosts == savedHosts)
|
||||
#expect(store.portForwardings(for: first.id).isEmpty)
|
||||
#expect(model.search("second").map(\.id) == [rule.id])
|
||||
#expect(model.search("Database").map(\.id) == [rule.id])
|
||||
#expect(model.search("8080").map(\.id) == [rule.id])
|
||||
try model.delete(rule.id)
|
||||
#expect(model.rules.isEmpty)
|
||||
#expect(store.hosts == savedHosts)
|
||||
}
|
||||
|
||||
@Test func failedSavesAndDeletesRollbackTheRuleSnapshot() throws {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let other = Host(name: "Other", host: "other.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let persistence = ForwardingPersistence(snapshot: VaultSnapshot(hosts: [host, other], portForwardings: [rule]))
|
||||
let store = HostLibraryModel(persistence: persistence)
|
||||
let model = ForwardingLibraryModel(library: store)
|
||||
persistence.fails = true
|
||||
var changed = rule
|
||||
changed.hostID = other.id
|
||||
changed.bindPort = 9000
|
||||
changed.colorTag = .orange
|
||||
#expect(throws: (any Error).self) { try model.save(changed) }
|
||||
#expect(model.rules == [rule])
|
||||
#expect(persistence.snapshot.portForwardings == [rule])
|
||||
#expect(store.hosts == [host, other])
|
||||
#expect(throws: (any Error).self) { try model.delete(rule.id) }
|
||||
#expect(model.rules == [rule])
|
||||
let new = PortForwardingRule(hostID: host.id, bindPort: 9090)
|
||||
#expect(throws: (any Error).self) { try model.save(new) }
|
||||
#expect(model.rules == [rule])
|
||||
persistence.fails = false
|
||||
try model.save(changed)
|
||||
#expect(model.rules == [changed])
|
||||
}
|
||||
|
||||
@Test func invalidHostIsRejectedAndOrphansRemainDeletable() throws {
|
||||
let orphan = PortForwardingRule(hostID: UUID())
|
||||
let store = HostLibraryModel(portForwardings: [orphan])
|
||||
let model = ForwardingLibraryModel(library: store)
|
||||
#expect(throws: (any Error).self) { try model.save(orphan) }
|
||||
try model.delete(orphan.id)
|
||||
#expect(model.rules.isEmpty)
|
||||
}
|
||||
|
||||
@Test func hostSaveFailurePreservesItsConfigurationAndRules() {
|
||||
var host = Host(name: "Server", host: "server.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let persistence = ForwardingPersistence(snapshot: VaultSnapshot(hosts: [host], portForwardings: [rule]))
|
||||
let store = HostLibraryModel(persistence: persistence)
|
||||
persistence.fails = true
|
||||
host.port = 2222
|
||||
#expect(!store.saveHost(host))
|
||||
#expect(store.host(host.id)?.port == 22)
|
||||
#expect(store.portForwardings == [rule])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ForwardingPersistence: VaultPersisting {
|
||||
var snapshot: VaultSnapshot
|
||||
var fails = false
|
||||
init(snapshot: VaultSnapshot) { self.snapshot = snapshot }
|
||||
func load() throws -> VaultSnapshot { snapshot }
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
|
||||
if fails { throw CocoaError(.fileWriteUnknown) }
|
||||
self.snapshot = snapshot
|
||||
return .empty
|
||||
}
|
||||
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {}
|
||||
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
||||
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ struct PersistenceCompatibilityTests {
|
||||
try first.mainContext.save()
|
||||
#expect(try second.mainContext.fetchCount(FetchDescriptor<PersistentHostGroup>()) == 0)
|
||||
#expect(Set(first.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
|
||||
#expect(TermindMigrationPlan.schemas.count == 19)
|
||||
#expect(TermindMigrationPlan.schemas.count == 20)
|
||||
#expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" })
|
||||
}
|
||||
|
||||
|
||||
@@ -7,31 +7,15 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite
|
||||
struct PortForwardingEditorPolicyTests {
|
||||
@Test(
|
||||
arguments: [false, true],
|
||||
[
|
||||
(false, false, true, true, false),
|
||||
(true, false, false, true, true),
|
||||
(false, true, false, false, false),
|
||||
(true, true, false, false, false),
|
||||
])
|
||||
func controlsKeepSavedEligibilityIntentAndActivityIndependent(
|
||||
hasSavedConfiguration: Bool,
|
||||
scenario: (enabled: Bool, active: Bool, canStartSaved: Bool, canEdit: Bool, waitsWhenUnsaved: Bool)
|
||||
) {
|
||||
@Test(arguments: [false, true], [false, true])
|
||||
func onlySavedStoppedRulesCanStart(hasSavedConfiguration: Bool, isActive: Bool) {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: hasSavedConfiguration ? rule : nil, savedHost: host,
|
||||
isEnabled: scenario.enabled, isRuntimeActive: scenario.active)
|
||||
|
||||
#expect(policy.hasSavedConfiguration == hasSavedConfiguration)
|
||||
#expect(policy.isEnabled == scenario.enabled)
|
||||
#expect(policy.isRuntimeActive == scenario.active)
|
||||
#expect(policy.canStart == (hasSavedConfiguration && scenario.canStartSaved))
|
||||
#expect(policy.canToggle)
|
||||
#expect(policy.canEdit == scenario.canEdit)
|
||||
#expect(policy.isWaitingForSave == (!hasSavedConfiguration && scenario.waitsWhenUnsaved))
|
||||
isRuntimeActive: isActive)
|
||||
#expect(policy.canStart == (hasSavedConfiguration && !isActive))
|
||||
#expect(policy.canEdit == !isActive)
|
||||
}
|
||||
|
||||
enum UnavailableConfiguration: CaseIterable {
|
||||
@@ -52,11 +36,10 @@ struct PortForwardingEditorPolicyTests {
|
||||
}
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: savedRule, savedHost: savedHost,
|
||||
isEnabled: false, isRuntimeActive: false)
|
||||
isRuntimeActive: false)
|
||||
|
||||
#expect(!policy.hasSavedConfiguration)
|
||||
#expect(!policy.canStart)
|
||||
#expect(policy.canToggle)
|
||||
#expect(policy.canEdit)
|
||||
}
|
||||
|
||||
@@ -68,7 +51,7 @@ struct PortForwardingEditorPolicyTests {
|
||||
savedRule.bindPort = invalidPort
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(draft), savedRule: savedRule, savedHost: host,
|
||||
isEnabled: false, isRuntimeActive: false)
|
||||
isRuntimeActive: false)
|
||||
|
||||
#expect(!policy.hasSavedConfiguration)
|
||||
#expect(!policy.canStart)
|
||||
|
||||
@@ -66,6 +66,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@" : {
|
||||
"comment" : "The title of the vault, when creating a new forwarding rule.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ — %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -2116,6 +2134,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add a host before creating a forwarding rule." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请先添加主机,再创建转发规则。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請先加入主機,再建立轉送規則。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add a host to connect to it." : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -2329,6 +2364,7 @@
|
||||
}
|
||||
},
|
||||
"Add Forwarding" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -6946,6 +6982,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Create a forwarding rule, then start it manually." : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "创建转发规则后,手动启动转发。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "建立轉送規則後,手動啟動轉送。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Created directories" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -7421,6 +7473,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete Forwarding Rules?" : {
|
||||
"comment" : "A confirmation dialog that asks the user to delete a set of forwarding rules.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "删除转发规则?"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "刪除轉送規則?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete Group" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -8125,7 +8195,6 @@
|
||||
}
|
||||
},
|
||||
"Direction" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -8177,6 +8246,7 @@
|
||||
},
|
||||
"Disable" : {
|
||||
"comment" : "A button that disables a forwarding rule.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -8195,6 +8265,7 @@
|
||||
},
|
||||
"Disable All" : {
|
||||
"comment" : "A button that disables all running port forwarding rules.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -8857,6 +8928,7 @@
|
||||
}
|
||||
},
|
||||
"Dynamic proxy (-D)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -9282,6 +9354,7 @@
|
||||
},
|
||||
"Enable All" : {
|
||||
"comment" : "A button that enables all forwarding rules.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -9349,6 +9422,7 @@
|
||||
},
|
||||
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved." : {
|
||||
"comment" : "A description of how changes to forwarding rules are saved.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -10759,6 +10833,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Forwarding ready" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "转发已就绪"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "轉送已就緒"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Free" : {
|
||||
"comment" : "A label for a free subscription.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -11670,6 +11761,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Host Unavailable" : {
|
||||
"comment" : "A label for the host of a port forwarding rule.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "主机不可用"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "主機無法使用"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Host: %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -13666,6 +13775,7 @@
|
||||
}
|
||||
},
|
||||
"Local forward (-L)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -15560,6 +15670,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Port Forwarding" : {
|
||||
"comment" : "A title for a view that shows when an app has no forwarding rules.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "没有端口转发"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "沒有連接埠轉送"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Providers" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -19350,6 +19478,7 @@
|
||||
}
|
||||
},
|
||||
"Remote forward (-R)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -21135,6 +21264,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select Host" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "选择主机"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "選擇主機"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select None" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -22577,6 +22723,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH %@ → This Device %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH %1$@→本机%2$@"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH %1$@→本機%2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH %@ → This Mac %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -22766,6 +22929,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Start" : {
|
||||
"comment" : "A button that starts a forwarding rule.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "启动"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "啟動"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Start a Conversation" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -24343,6 +24524,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"The forwarding rule is unavailable." : {
|
||||
"comment" : "A message that is displayed when a forwarding rule is unavailable.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "转发规则不可用。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "轉送規則無法使用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"The forwarding SSH connection closed" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -26169,6 +26368,7 @@
|
||||
},
|
||||
"This forwarding will start after the host is saved." : {
|
||||
"comment" : "A description of the state of a forwarding that is waiting for the host to be saved.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -28683,6 +28883,7 @@
|
||||
},
|
||||
"Waiting for Save" : {
|
||||
"comment" : "A label displayed in a forwarding's row that indicates that the forwarding is waiting for the host to be saved.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
||||
@@ -8,6 +8,7 @@ public nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
|
||||
case sftpSession
|
||||
case serialSession
|
||||
case metrics
|
||||
case portForwarding
|
||||
}
|
||||
|
||||
public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
|
||||
@@ -334,6 +335,7 @@ extension ConnectionPurpose {
|
||||
case .sftpSession: "SFTP session"
|
||||
case .serialSession: "serial session"
|
||||
case .metrics: "metrics"
|
||||
case .portForwarding: "port forwarding"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,31 @@ public final class ForwardingLibraryModel {
|
||||
public var rules: [PortForwardingRule] { library.portForwardings }
|
||||
|
||||
public func save(_ rule: PortForwardingRule) throws {
|
||||
guard rule.isValid, let host = library.host(rule.hostID) else { throw CocoaError(.coderInvalidValue) }
|
||||
let rules = library.portForwardings(for: host.id).filter { $0.id != rule.id } + [rule]
|
||||
guard library.upsert(host, replacingPortForwardings: rules) else {
|
||||
guard library.upsert(rule) else {
|
||||
throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save"))
|
||||
}
|
||||
}
|
||||
|
||||
public func delete(_ id: UUID) throws {
|
||||
guard let rule = library.portForwarding(id), let host = library.host(rule.hostID) else { return }
|
||||
guard
|
||||
library.upsert(host, replacingPortForwardings: library.portForwardings(for: host.id).filter { $0.id != id })
|
||||
else {
|
||||
public func delete(_ id: UUID) throws { try delete([id]) }
|
||||
|
||||
public func delete(_ ids: Set<UUID>) throws {
|
||||
guard library.deletePortForwardings(ids) else {
|
||||
throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save"))
|
||||
}
|
||||
}
|
||||
|
||||
public func search(_ query: String) -> [PortForwardingRule] {
|
||||
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return rules }
|
||||
return rules.filter { rule in
|
||||
[
|
||||
rule.label, rule.bindAddress, rule.targetHost ?? "", rule.targetPort.map(String.init) ?? "",
|
||||
library.host(rule.hostID)?.name ?? "", library.host(rule.hostID)?.displayAddress ?? "",
|
||||
]
|
||||
.contains { $0.localizedCaseInsensitiveContains(query) }
|
||||
}
|
||||
}
|
||||
|
||||
public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? {
|
||||
library.portForwarding(id)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
/// references are removed and the controller can stop listeners cleanly.
|
||||
public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
|
||||
public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)?
|
||||
/// Reconciles locally enabled rules after their saved configuration has been added,
|
||||
/// replaced, removed, or rolled back.
|
||||
public var didReplacePortForwardings: (([PortForwardingRule.ID]) -> Void)?
|
||||
public var didChangeHostConnection: ((Host.ID) -> Void)?
|
||||
public var didPersistCore: (() -> Void)? {
|
||||
get { vault.didPersist }
|
||||
set { vault.didPersist = newValue }
|
||||
@@ -827,71 +825,16 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func upsert(_ host: Host) {
|
||||
guard validatedRoute(for: host) != nil else {
|
||||
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
|
||||
return
|
||||
}
|
||||
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
|
||||
var updated = host
|
||||
updated.detectedDistribution = hosts[index].detectedDistribution
|
||||
hosts[index] = updated
|
||||
} else {
|
||||
var new = host
|
||||
new.sortIndex = nextSortIndex(in: host.groupID)
|
||||
hosts.append(new)
|
||||
}
|
||||
persistCore()
|
||||
}
|
||||
public func upsert(_ host: Host) { _ = saveHost(host) }
|
||||
|
||||
/// Commits one host and its complete editor-owned collections together.
|
||||
///
|
||||
/// `HostEditorView` owns the Host, forwarding rules, and Known Hosts as
|
||||
/// drafts, so persisting them through separate calls would let Save only
|
||||
/// partly succeed. The in-memory projections are
|
||||
/// restored if SwiftData rejects the replacement, and rules whose live
|
||||
/// connection would no longer match the saved configuration are stopped
|
||||
/// before their old records disappear.
|
||||
@discardableResult
|
||||
public func upsert(
|
||||
_ host: Host,
|
||||
replacingPortForwardings rules: [PortForwardingRule]
|
||||
) -> Bool {
|
||||
public func saveHost(_ host: Host) -> Bool {
|
||||
guard validatedRoute(for: host) != nil else {
|
||||
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
|
||||
return false
|
||||
}
|
||||
let normalizedRules = rules.map { rule in
|
||||
var normalized = rule
|
||||
normalized.hostID = host.id
|
||||
if normalized.kind == .dynamic {
|
||||
normalized.targetHost = nil
|
||||
normalized.targetPort = nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
guard normalizedRules.allSatisfy(\.isValid),
|
||||
Set(normalizedRules.map(\.id)).count == normalizedRules.count
|
||||
else {
|
||||
storageError = String(localized: "One or more forwarding rules are incomplete.")
|
||||
return false
|
||||
}
|
||||
|
||||
let oldHosts = hosts
|
||||
let oldRules = portForwardings
|
||||
let existingHost = self.host(host.id)
|
||||
let existingRules = portForwardings(for: host.id)
|
||||
let existingRuleIDs = Set(existingRules.map(\.id))
|
||||
let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) })
|
||||
var rulesToStop = Set(
|
||||
existingRules.compactMap { old in nextByID[old.id] == old ? nil : old.id }
|
||||
)
|
||||
if let existingHost, existingHost.sshConnectionFields != host.sshConnectionFields {
|
||||
rulesToStop.formUnion(existingRules.map(\.id))
|
||||
}
|
||||
let affectedRuleIDs = Array(rulesToStop)
|
||||
if !affectedRuleIDs.isEmpty { willDeletePortForwardings?(affectedRuleIDs) }
|
||||
|
||||
let previous = hosts
|
||||
let old = self.host(host.id)
|
||||
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
|
||||
var updated = host
|
||||
updated.detectedDistribution = hosts[index].detectedDistribution
|
||||
@@ -901,17 +844,11 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
new.sortIndex = nextSortIndex(in: host.groupID)
|
||||
hosts.append(new)
|
||||
}
|
||||
portForwardings.removeAll { $0.hostID == host.id }
|
||||
portForwardings.append(contentsOf: normalizedRules)
|
||||
guard persistCore() else {
|
||||
hosts = oldHosts
|
||||
portForwardings = oldRules
|
||||
if !affectedRuleIDs.isEmpty { didReplacePortForwardings?(affectedRuleIDs) }
|
||||
hosts = previous
|
||||
return false
|
||||
}
|
||||
let addedRuleIDs = Set(normalizedRules.map(\.id)).subtracting(existingRuleIDs)
|
||||
let savedRuleIDs = Array(rulesToStop.union(addedRuleIDs))
|
||||
if !savedRuleIDs.isEmpty { didReplacePortForwardings?(savedRuleIDs) }
|
||||
if let old, !old.hasSameConnectionRoute(as: host) { didChangeHostConnection?(host.id) }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1523,19 +1460,39 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
persistCore()
|
||||
}
|
||||
|
||||
public func upsert(_ forwarding: PortForwardingRule) {
|
||||
guard forwarding.isValid, host(forwarding.hostID) != nil else { return }
|
||||
var normalized = forwarding
|
||||
if normalized.kind == .dynamic {
|
||||
normalized.targetHost = nil
|
||||
normalized.targetPort = nil
|
||||
@discardableResult
|
||||
public func upsert(_ forwarding: PortForwardingRule) -> Bool {
|
||||
guard let normalized = PortForwardingEditorPolicy.normalized(forwarding), host(forwarding.hostID) != nil else {
|
||||
storageError = String(localized: "The forwarding rule is incomplete.")
|
||||
return false
|
||||
}
|
||||
let previous = portForwardings
|
||||
if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) {
|
||||
if portForwardings[index] == normalized { return true }
|
||||
willDeletePortForwardings?([normalized.id])
|
||||
portForwardings[index] = normalized
|
||||
} else {
|
||||
portForwardings.append(normalized)
|
||||
}
|
||||
persistCore()
|
||||
guard persistCore() else {
|
||||
portForwardings = previous
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deletePortForwardings(_ ids: Set<PortForwardingRule.ID>) -> Bool {
|
||||
let affected = portForwardings.filter { ids.contains($0.id) }.map(\.id)
|
||||
guard !affected.isEmpty else { return true }
|
||||
let previous = portForwardings
|
||||
willDeletePortForwardings?(affected)
|
||||
portForwardings.removeAll { ids.contains($0.id) }
|
||||
guard persistCore() else {
|
||||
portForwardings = previous
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Root jump first and destination last. A nil result means the proposed
|
||||
@@ -1912,23 +1869,6 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
}
|
||||
|
||||
extension Host {
|
||||
fileprivate var sshConnectionFields: SSHConnectionFields {
|
||||
SSHConnectionFields(
|
||||
host: host, port: port, identityID: identityID,
|
||||
jumpHostID: jumpHostID, proxy: proxy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SSHConnectionFields: Equatable {
|
||||
let host: String
|
||||
let port: Int
|
||||
let identityID: Identity.ID?
|
||||
let jumpHostID: Host.ID?
|
||||
let proxy: HostProxy?
|
||||
}
|
||||
|
||||
// MARK: - Manual ordering
|
||||
|
||||
/// A deterministic tie-break keeps hand-built preview data usable before its
|
||||
|
||||
@@ -6,23 +6,19 @@ import struct TermindDomain.Host
|
||||
/// Shared edit and start policy for the platform-specific forwarding editors.
|
||||
public nonisolated struct PortForwardingEditorPolicy {
|
||||
public let hasSavedConfiguration: Bool
|
||||
public let isEnabled: Bool
|
||||
public let isRuntimeActive: Bool
|
||||
|
||||
public var canStart: Bool { hasSavedConfiguration && !isEnabled && !isRuntimeActive }
|
||||
public var canToggle: Bool { true }
|
||||
public var canStart: Bool { hasSavedConfiguration && !isRuntimeActive }
|
||||
public var canEdit: Bool { !isRuntimeActive }
|
||||
public var isWaitingForSave: Bool { isEnabled && !hasSavedConfiguration && !isRuntimeActive }
|
||||
|
||||
public init(
|
||||
rule: PortForwardingRule?,
|
||||
savedRule: PortForwardingRule?, savedHost: Host?,
|
||||
isEnabled: Bool, isRuntimeActive: Bool
|
||||
isRuntimeActive: Bool
|
||||
) {
|
||||
hasSavedConfiguration =
|
||||
savedRule?.isValid == true && savedRule?.hostID == savedHost?.id
|
||||
&& (rule == nil || rule?.id == savedRule?.id)
|
||||
self.isEnabled = isEnabled
|
||||
self.isRuntimeActive = isRuntimeActive
|
||||
}
|
||||
|
||||
|
||||
@@ -336,13 +336,4 @@ public nonisolated enum TermindSSHError: LocalizedError, Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
public var isRetryableForwardingFailure: Bool {
|
||||
switch self {
|
||||
case .socket, .library, .handshake, .disconnected: true
|
||||
case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged,
|
||||
.missingCredential, .keyPassphraseRequired, .missingUsername,
|
||||
.untrustedHost, .mismatchedHostKey:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ public nonisolated enum SettingsKey {
|
||||
public static let iCloudSyncEnabled = "icloud.sync.enabled"
|
||||
public static let metricsSyncEnabled = "metrics.sync.enabled"
|
||||
public static let metricsSyncEnvironment = "metrics.sync.environment"
|
||||
/// Device-local forwarding intent. It is deliberately excluded from Settings and Vault sync.
|
||||
public static let portForwardingEnabledRuleIDs = "portForwarding.enabledRuleIDs"
|
||||
public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
|
||||
public static let settingsSyncEnabled = "settings.sync.enabled"
|
||||
public static let settingsSyncEnvironment = "settings.sync.environment"
|
||||
|
||||
@@ -14,12 +14,13 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
|
||||
public var targetHost: String? = "localhost"
|
||||
public var targetPort: Int? = 80
|
||||
public var label: String = ""
|
||||
public var colorTag: TagColor = .none
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(), hostID: Host.ID, kind: Kind = .local,
|
||||
bindHost: String = "127.0.0.1", bindPort: Int = 8080,
|
||||
targetHost: String? = "localhost", targetPort: Int? = 80,
|
||||
label: String = ""
|
||||
label: String = "", colorTag: TagColor = .none
|
||||
) {
|
||||
self.id = id
|
||||
self.hostID = hostID
|
||||
@@ -29,6 +30,25 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
|
||||
self.targetHost = kind == .dynamic ? nil : targetHost
|
||||
self.targetPort = kind == .dynamic ? nil : targetPort
|
||||
self.label = label
|
||||
self.colorTag = colorTag
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, hostID, kind, bindHost, bindPort, targetHost, targetPort, label, colorTag
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
id: try values.decode(UUID.self, forKey: .id),
|
||||
hostID: try values.decode(UUID.self, forKey: .hostID),
|
||||
kind: try values.decode(Kind.self, forKey: .kind),
|
||||
bindHost: try values.decode(String.self, forKey: .bindHost),
|
||||
bindPort: try values.decode(Int.self, forKey: .bindPort),
|
||||
targetHost: try values.decodeIfPresent(String.self, forKey: .targetHost),
|
||||
targetPort: try values.decodeIfPresent(Int.self, forKey: .targetPort),
|
||||
label: try values.decode(String.self, forKey: .label),
|
||||
colorTag: (try? values.decode(TagColor.self, forKey: .colorTag)) ?? .none)
|
||||
}
|
||||
|
||||
public var isValid: Bool {
|
||||
@@ -47,7 +67,8 @@ public nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, S
|
||||
case .local:
|
||||
"\(bindAddress) → \(Self.address(targetHost ?? "", port: targetPort ?? 0))"
|
||||
case .remote:
|
||||
String(localized: "SSH \(bindAddress) → This Mac \(Self.address(targetHost ?? "", port: targetPort ?? 0))")
|
||||
String(
|
||||
localized: "SSH \(bindAddress) → This Device \(Self.address(targetHost ?? "", port: targetPort ?? 0))")
|
||||
case .dynamic: "SOCKS5 \(bindAddress)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// A persisted label color shared by hosts, keys, and identities.
|
||||
/// A persisted label color shared by hosts, keys, identities, and forwarding rules.
|
||||
/// Presentation details live in TermindPresentation so the domain value remains
|
||||
/// usable without SwiftUI.
|
||||
public nonisolated enum TagColor: String, CaseIterable, Codable, Sendable, Identifiable {
|
||||
|
||||
@@ -426,6 +426,7 @@ public final class PersistentPortForwardingRule {
|
||||
public var targetHost: String?
|
||||
public var targetPort: Int?
|
||||
public var label = ""
|
||||
public var colorTag = TagColor.none.rawValue
|
||||
|
||||
public init(_ value: PortForwardingRule) {
|
||||
id = value.id
|
||||
@@ -440,6 +441,7 @@ public final class PersistentPortForwardingRule {
|
||||
targetHost = value.kind == .dynamic ? nil : value.targetHost
|
||||
targetPort = value.kind == .dynamic ? nil : value.targetPort
|
||||
label = value.label
|
||||
colorTag = value.colorTag.rawValue
|
||||
}
|
||||
|
||||
public var value: PortForwardingRule {
|
||||
@@ -447,7 +449,8 @@ public final class PersistentPortForwardingRule {
|
||||
id: id, hostID: hostID,
|
||||
kind: PortForwardingRule.Kind(rawValue: kind) ?? .local,
|
||||
bindHost: bindHost, bindPort: bindPort,
|
||||
targetHost: targetHost, targetPort: targetPort, label: label
|
||||
targetHost: targetHost, targetPort: targetPort, label: label,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -503,7 +506,7 @@ public enum TermindSchemaV1: VersionedSchema {
|
||||
PersistentKnownHost.self,
|
||||
PersistentSnippetPackage.self,
|
||||
PersistentSnippet.self,
|
||||
PersistentPortForwardingRule.self,
|
||||
TermindSchemaV19ForwardingModels.PersistentPortForwardingRule.self,
|
||||
PersistentOutboxJob.self,
|
||||
PersistentPendingDeletion.self,
|
||||
PersistentDatabaseMetadata.self,
|
||||
@@ -693,6 +696,15 @@ public enum TermindSchemaV19: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindSchemaV20: VersionedSchema {
|
||||
public static let versionIdentifier = Schema.Version(20, 0, 0)
|
||||
public static var models: [any PersistentModel.Type] {
|
||||
TermindSchemaV19.models.filter {
|
||||
$0 != TermindSchemaV19ForwardingModels.PersistentPortForwardingRule.self
|
||||
} + [PersistentPortForwardingRule.self]
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
public static var schemas: [any VersionedSchema.Type] {
|
||||
[
|
||||
@@ -701,7 +713,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self,
|
||||
TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self,
|
||||
TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self,
|
||||
TermindSchemaV18.self, TermindSchemaV19.self,
|
||||
TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -762,6 +774,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV16.self, toVersion: TermindSchemaV17.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV17.self, toVersion: TermindSchemaV18.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV18.self, toVersion: TermindSchemaV19.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV19.self, toVersion: TermindSchemaV20.self),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftData
|
||||
/// choose storage and own the container lifetime, never the model set.
|
||||
@MainActor
|
||||
public enum TermindPersistence {
|
||||
public static let schema = Schema(versionedSchema: TermindSchemaV19.self)
|
||||
public static let schema = Schema(versionedSchema: TermindSchemaV20.self)
|
||||
|
||||
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
|
||||
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindDomain
|
||||
|
||||
/// Freeze the V1–V19 forwarding entity before adding its optional color in V20.
|
||||
public enum TermindSchemaV19ForwardingModels {
|
||||
@Model
|
||||
public final class PersistentPortForwardingRule {
|
||||
@Attribute(.unique) public var id: UUID = UUID()
|
||||
public var hostID: UUID = UUID()
|
||||
public var kind = PortForwardingRule.Kind.local.rawValue
|
||||
public var bindHost = "127.0.0.1"
|
||||
public var bindPort = 8080
|
||||
public var targetHost: String?
|
||||
public var targetPort: Int?
|
||||
public var label = ""
|
||||
|
||||
public init(_ value: PortForwardingRule) {
|
||||
id = value.id
|
||||
hostID = value.hostID
|
||||
kind = value.kind.rawValue
|
||||
bindHost = value.bindHost
|
||||
bindPort = value.bindPort
|
||||
targetHost = value.kind == .dynamic ? nil : value.targetHost
|
||||
targetPort = value.kind == .dynamic ? nil : value.targetPort
|
||||
label = value.label
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
|
||||
/// The persisted tag palette, shared by host, identity and key editors on both platforms.
|
||||
/// The persisted tag palette shared by Vault editors on both platforms.
|
||||
public struct ColorTagPicker: View {
|
||||
public enum Layout { case compact, touch }
|
||||
@Binding private var selection: TagColor
|
||||
|
||||
@@ -184,6 +184,7 @@ private struct JourneyEndpointView: View {
|
||||
case .sshSession: "apple.terminal.fill"
|
||||
case .sftpSession: "folder.fill"
|
||||
case .metrics: "waveform.path.ecg"
|
||||
case .portForwarding: "arrow.left.arrow.right"
|
||||
case .serialSession: "cable.connector"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ public struct ConnectionJourneyHeader: View {
|
||||
private var detail: String {
|
||||
let protocolName: String =
|
||||
switch journey.purpose {
|
||||
case .sshSession, .metrics: "SSH"
|
||||
case .sshSession, .metrics, .portForwarding: "SSH"
|
||||
case .sftpSession: "SFTP"
|
||||
case .serialSession: String(localized: "Serial")
|
||||
}
|
||||
@@ -80,6 +80,7 @@ public struct ConnectionJourneyHeader: View {
|
||||
case .sftpSession: "folder.fill.badge.gearshape"
|
||||
case .serialSession: "cable.connector"
|
||||
case .metrics: "waveform.path.ecg"
|
||||
case .portForwarding: "arrow.left.arrow.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +113,7 @@ public struct ConnectionJourneyStatus: View {
|
||||
case .sshSession, .serialSession: String(localized: "Terminal ready")
|
||||
case .sftpSession: String(localized: "File browser ready")
|
||||
case .metrics: String(localized: "Metrics ready")
|
||||
case .portForwarding: String(localized: "Forwarding ready")
|
||||
}
|
||||
case .failed(let message): message
|
||||
case .hidden: ""
|
||||
|
||||
@@ -11,6 +11,16 @@ public struct PortForwardingFields: View {
|
||||
.textFieldStyle(
|
||||
.automatic
|
||||
).multilineTextAlignment(.leading)
|
||||
#if os(macOS)
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $rule.colorTag, layout: .compact)
|
||||
}
|
||||
#else
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Color")
|
||||
ColorTagPicker(selection: $rule.colorTag, layout: .touch)
|
||||
}
|
||||
#endif
|
||||
PortForwardingDirectionPicker(kind: $rule.kind)
|
||||
Text(directionDescription).font(.caption).foregroundStyle(.secondary)
|
||||
LabeledContent("Bind Address") {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
public struct PortForwardingHostPicker: View {
|
||||
@Binding private var rule: PortForwardingRule
|
||||
private let hosts: [Host]
|
||||
|
||||
public init(rule: Binding<PortForwardingRule>, hosts: [Host]) {
|
||||
_rule = rule
|
||||
self.hosts = hosts
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section("Host") {
|
||||
Picker("Host", selection: selection) {
|
||||
Text("Select Host").tag(UUID?.none)
|
||||
ForEach(hosts) { host in
|
||||
Text("\(host.name) · \(host.displayAddress)").tag(Optional(host.id))
|
||||
}
|
||||
}
|
||||
if hosts.isEmpty {
|
||||
Text("Add a host before creating a forwarding rule.").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selection: Binding<UUID?> {
|
||||
Binding(
|
||||
get: { hosts.contains { $0.id == rule.hostID } ? rule.hostID : nil },
|
||||
set: { rule.hostID = $0 ?? UUID() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
extension PortForwardingRule {
|
||||
public nonisolated func displayName(hostName: String?) -> String {
|
||||
let name = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard name.isEmpty else { return name }
|
||||
return "\(hostName ?? String(localized: "Host Unavailable")) (\(kind.sshArgument))"
|
||||
}
|
||||
}
|
||||
|
||||
extension PortForwardingRule.Kind {
|
||||
public nonisolated var sshArgument: String {
|
||||
switch self {
|
||||
case .local: "-L"
|
||||
case .remote: "-R"
|
||||
case .dynamic: "-D"
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated var shortDisplayName: String {
|
||||
switch self {
|
||||
case .local: String(localized: "Local")
|
||||
case .remote: String(localized: "Remote")
|
||||
case .dynamic: String(localized: "Dynamic")
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated var displayName: String {
|
||||
switch self {
|
||||
case .local: String(localized: "Local forward (-L)")
|
||||
case .remote: String(localized: "Remote forward (-R)")
|
||||
case .dynamic: String(localized: "Dynamic proxy (-D)")
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated var symbol: String {
|
||||
switch self {
|
||||
case .local: "arrow.right"
|
||||
case .remote: "arrow.left"
|
||||
case .dynamic: "shuffle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,20 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
|
||||
case connecting
|
||||
case starting
|
||||
case running(activeConnections: Int)
|
||||
case reconnecting(attempt: Int, nextRetry: Date, message: String)
|
||||
case failed(String)
|
||||
|
||||
public var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
|
||||
.starting, .running, .reconnecting:
|
||||
true
|
||||
case .stopped, .failed: false
|
||||
default: true
|
||||
}
|
||||
}
|
||||
|
||||
public var isFailed: Bool {
|
||||
if case .failed = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .stopped: String(localized: "Stopped")
|
||||
@@ -38,336 +40,375 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
|
||||
: count == 1
|
||||
? String(localized: "Running · 1 connection")
|
||||
: String(localized: "Running · \(count) connections")
|
||||
case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)")
|
||||
case .failed(let message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps device-local enabled intent separate from process-local SSH resources.
|
||||
/// Enabled rule IDs are not part of Vault or Settings sync.
|
||||
/// A host's manually opened transport and its shared authentication surface.
|
||||
@Observable
|
||||
public final class PortForwardingConnection {
|
||||
public let hostID: Host.ID
|
||||
public let connection = RemoteSessionConnectionController()
|
||||
public var journey: ConnectionJourney { connection.journey }
|
||||
public private(set) var authenticationPrompt: SSHAuthenticationPrompt?
|
||||
public fileprivate(set) var hostKeyRequestID: UUID?
|
||||
fileprivate var route: [Host]
|
||||
fileprivate var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
|
||||
fileprivate var attemptID: UUID?
|
||||
fileprivate var session: (any SSHForwardingSessionProtocol)?
|
||||
fileprivate var task: Task<Void, Never>?
|
||||
fileprivate var connected = false
|
||||
|
||||
fileprivate init(host: Host, route: [Host]) {
|
||||
hostID = host.id
|
||||
self.route = route
|
||||
}
|
||||
|
||||
fileprivate func setPrompt(_ prompt: SSHAuthenticationPrompt?) { authenticationPrompt = prompt }
|
||||
}
|
||||
|
||||
/// Runtime intent exists only between explicit Start and Stop/failure. No defaults or retry timers.
|
||||
@Observable
|
||||
public final class PortForwardingController {
|
||||
public typealias SessionFactory =
|
||||
@MainActor (
|
||||
@escaping SSHForwardingSession.ConnectionHandler, @escaping SSHForwardingSession.FailureHandler
|
||||
) -> any SSHForwardingSessionProtocol
|
||||
|
||||
private let hosts: HostLibraryModel
|
||||
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
|
||||
private let defaults: UserDefaults?
|
||||
private let enabledDefaultsKey: String
|
||||
private let makeSession: SessionFactory
|
||||
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
|
||||
private var enabled: Set<PortForwardingRule.ID>
|
||||
/// Enabled IDs created in this process before their rules have been saved.
|
||||
/// Unlike stale IDs restored from defaults, these survive reconciliation until
|
||||
/// their editor explicitly discards them or a save makes them runnable.
|
||||
private var pending: Set<PortForwardingRule.ID> = []
|
||||
private var desired: Set<PortForwardingRule.ID> = []
|
||||
private var sessions: [Host.ID: SSHForwardingSession] = [:]
|
||||
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
|
||||
private var reconnectTasks: [Host.ID: Task<Void, Never>] = [:]
|
||||
private var reconnectAttempts: [Host.ID: Int] = [:]
|
||||
private var connectedAt: [Host.ID: Date] = [:]
|
||||
private var ruleHostIDs: [PortForwardingRule.ID: Host.ID]
|
||||
private var stoppingTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
|
||||
private var stoppingGenerations: [PortForwardingRule.ID: UUID] = [:]
|
||||
private var connections: [Host.ID: PortForwardingConnection] = [:]
|
||||
private var presentations: [PortForwardingRule.ID: PortForwardingConnection] = [:]
|
||||
private var generations: [PortForwardingRule.ID: UUID] = [:]
|
||||
private var ruleTasks: [PortForwardingRule.ID: Task<Void, Never>] = [:]
|
||||
private var savingAuthentication: PortForwardingConnection?
|
||||
|
||||
public init(
|
||||
hosts: HostLibraryModel, defaults: UserDefaults? = nil,
|
||||
enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs,
|
||||
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
|
||||
hosts: HostLibraryModel,
|
||||
makeSession: @escaping SessionFactory = {
|
||||
SSHForwardingSession(connectionHandler: $0, failureHandler: $1)
|
||||
}
|
||||
) {
|
||||
self.hosts = hosts
|
||||
self.keyboardInteractive = keyboardInteractive
|
||||
self.defaults = defaults
|
||||
self.enabledDefaultsKey = enabledDefaultsKey
|
||||
enabled = Set(
|
||||
defaults?.stringArray(forKey: enabledDefaultsKey)?.compactMap(UUID.init(uuidString:)) ?? [])
|
||||
ruleHostIDs = Dictionary(uniqueKeysWithValues: hosts.portForwardings.map { ($0.id, $0.hostID) })
|
||||
self.makeSession = makeSession
|
||||
}
|
||||
|
||||
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState {
|
||||
states[id] ?? .stopped
|
||||
}
|
||||
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
|
||||
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
|
||||
|
||||
public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { enabled.contains(id) }
|
||||
|
||||
public func setEnabled(_ value: Bool, for id: PortForwardingRule.ID) {
|
||||
if value {
|
||||
enabled.insert(id)
|
||||
if let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil {
|
||||
pending.remove(id)
|
||||
} else {
|
||||
pending.insert(id)
|
||||
}
|
||||
persistEnabled()
|
||||
if !pending.contains(id) { activate(id) }
|
||||
public func start(_ id: PortForwardingRule.ID) {
|
||||
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
|
||||
let host = hosts.host(rule.hostID)
|
||||
else { return }
|
||||
let context: PortForwardingConnection
|
||||
if let existing = connections[host.id], !activeIDs(existing).isEmpty {
|
||||
context = existing
|
||||
} else {
|
||||
enabled.remove(id)
|
||||
pending.remove(id)
|
||||
persistEnabled()
|
||||
suspend(id)
|
||||
context = PortForwardingConnection(host: host, route: hosts.route(for: host))
|
||||
connections[host.id] = context
|
||||
}
|
||||
generations[id] = UUID()
|
||||
presentations[id] = context
|
||||
states[id] = .preparing
|
||||
if context.connected {
|
||||
startRule(rule, in: context)
|
||||
} else if context.task == nil && context.authenticationPrompt == nil {
|
||||
open(context)
|
||||
} else {
|
||||
states[id] =
|
||||
context.authenticationPrompt != nil || context.connection.keyboardAuthentication.current != nil
|
||||
? .awaitingAuthentication : context.connection.pendingHostKey != nil ? .awaitingTrust : .connecting
|
||||
}
|
||||
}
|
||||
|
||||
public func setEnabled(_ value: Bool, for ids: [PortForwardingRule.ID]) {
|
||||
for id in ids { setEnabled(value, for: id) }
|
||||
}
|
||||
public func retry(_ id: PortForwardingRule.ID) { start(id) }
|
||||
|
||||
/// Restores all, or a changed subset, after launch, foregrounding, or a saved configuration replacement.
|
||||
public func resumeEnabled(_ ids: [PortForwardingRule.ID]? = nil) {
|
||||
var valid: Set<PortForwardingRule.ID> = []
|
||||
for id in enabled {
|
||||
guard let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil else { continue }
|
||||
ruleHostIDs[rule.id] = rule.hostID
|
||||
valid.insert(id)
|
||||
}
|
||||
pending.subtract(valid)
|
||||
let reconciled = valid.union(pending.intersection(enabled))
|
||||
if reconciled != enabled {
|
||||
enabled = reconciled
|
||||
persistEnabled()
|
||||
}
|
||||
let targets = ids.map { valid.intersection($0) } ?? valid
|
||||
targets.forEach(activate)
|
||||
}
|
||||
|
||||
/// Stops live resources while preserving the device-local enabled intent.
|
||||
public func suspend(_ id: PortForwardingRule.ID) {
|
||||
let hostID = hosts.portForwarding(id)?.hostID ?? ruleHostIDs[id]
|
||||
let session = hostID.flatMap { sessions[$0] }
|
||||
desired.remove(id)
|
||||
public func stop(_ id: PortForwardingRule.ID) {
|
||||
generations[id] = nil
|
||||
states[id] = .stopped
|
||||
let previous = stoppingTasks[id]
|
||||
let generation = UUID()
|
||||
stoppingGenerations[id] = generation
|
||||
let task = Task {
|
||||
guard let context = presentations.removeValue(forKey: id) else { return }
|
||||
let session = context.session
|
||||
let previous = ruleTasks[id]
|
||||
ruleTasks[id] = Task {
|
||||
if let previous { await previous.value }
|
||||
if let session { await session.stop(id) }
|
||||
}
|
||||
stoppingTasks[id] = task
|
||||
Task { [weak self] in
|
||||
await task.value
|
||||
guard let self, self.stoppingGenerations[id] == generation else { return }
|
||||
self.stoppingTasks[id] = nil
|
||||
self.stoppingGenerations[id] = nil
|
||||
if let hostID { await self.disconnectHostIfIdle(hostID) }
|
||||
}
|
||||
}
|
||||
|
||||
public func suspend(_ ids: [PortForwardingRule.ID]) { ids.forEach(suspend) }
|
||||
|
||||
/// Forgets enabled intent only for rules that were enabled before their first save.
|
||||
public func discardPending(_ ids: [PortForwardingRule.ID]) {
|
||||
let discarded = pending.intersection(ids)
|
||||
guard !discarded.isEmpty else { return }
|
||||
pending.subtract(discarded)
|
||||
enabled.subtract(discarded)
|
||||
for id in discarded { states[id] = nil }
|
||||
persistEnabled()
|
||||
if activeIDs(context).isEmpty { retire(context) }
|
||||
}
|
||||
|
||||
public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) }
|
||||
public func stopAll() { stop(Array(presentations.keys)) }
|
||||
public func remove(_ ids: [PortForwardingRule.ID]) {
|
||||
for id in ids {
|
||||
enabled.remove(id)
|
||||
pending.remove(id)
|
||||
suspend(id)
|
||||
ruleHostIDs[id] = nil
|
||||
}
|
||||
persistEnabled()
|
||||
stop(ids)
|
||||
for id in ids { states[id] = nil }
|
||||
}
|
||||
|
||||
public func suspendAll() {
|
||||
let all = Array(enabled.union(desired))
|
||||
desired.removeAll()
|
||||
for id in all { states[id] = .stopped }
|
||||
let active = sessions
|
||||
sessions.removeAll()
|
||||
for task in connectionTasks.values { task.cancel() }
|
||||
connectionTasks.removeAll()
|
||||
for task in reconnectTasks.values { task.cancel() }
|
||||
reconnectTasks.removeAll()
|
||||
reconnectAttempts.removeAll()
|
||||
connectedAt.removeAll()
|
||||
for task in stoppingTasks.values { task.cancel() }
|
||||
stoppingTasks.removeAll()
|
||||
stoppingGenerations.removeAll()
|
||||
Task { for session in active.values { await session.disconnect() } }
|
||||
}
|
||||
|
||||
private func activate(_ id: PortForwardingRule.ID) {
|
||||
guard enabled.contains(id), !desired.contains(id) else { return }
|
||||
if let rule = hosts.portForwarding(id) { ruleHostIDs[id] = rule.hostID }
|
||||
desired.insert(id)
|
||||
states[id] = .preparing
|
||||
let stopping = stoppingTasks[id]
|
||||
Task { [weak self] in
|
||||
if let stopping { await stopping.value }
|
||||
guard let self, self.desired.contains(id), self.enabled.contains(id),
|
||||
let rule = self.hosts.portForwarding(id)
|
||||
else { return }
|
||||
await self.start(rule)
|
||||
/// Connection edits include jump hosts. Authentication's own explicit save may continue its opening.
|
||||
public func hostConfigurationChanged(_ hostID: Host.ID) {
|
||||
for context in Array(connections.values) where context.route.contains(where: { $0.id == hostID }) {
|
||||
guard context !== savingAuthentication else { continue }
|
||||
stop(activeIDs(context))
|
||||
}
|
||||
}
|
||||
|
||||
private func persistEnabled() {
|
||||
defaults?.set(enabled.map(\.uuidString).sorted(), forKey: enabledDefaultsKey)
|
||||
}
|
||||
|
||||
private func start(_ rule: PortForwardingRule) async {
|
||||
do {
|
||||
guard rule.isValid else {
|
||||
throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete."))
|
||||
}
|
||||
guard let host = hosts.host(rule.hostID) else {
|
||||
throw TermindSSHError.route(String(localized: "The forwarding host is unavailable."))
|
||||
}
|
||||
let session: SSHForwardingSession
|
||||
if let existing = sessions[host.id] {
|
||||
session = existing
|
||||
} else {
|
||||
let created = makeSession(for: host.id)
|
||||
sessions[host.id] = created
|
||||
session = created
|
||||
states[rule.id] = .connecting
|
||||
let task = Task {
|
||||
let configuration = try hosts.sshConnectionConfiguration(for: host)
|
||||
try await created.connect(
|
||||
SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive))
|
||||
}
|
||||
connectionTasks[host.id] = task
|
||||
do { try await task.value } catch {
|
||||
sessions[host.id] = nil
|
||||
connectionTasks[host.id] = nil
|
||||
throw error
|
||||
}
|
||||
connectionTasks[host.id] = nil
|
||||
connectedAt[host.id] = Date()
|
||||
}
|
||||
if let task = connectionTasks[host.id] { try await task.value }
|
||||
guard desired.contains(rule.id) else { return }
|
||||
states[rule.id] = .starting
|
||||
try await session.start(rule)
|
||||
guard desired.contains(rule.id) else {
|
||||
await session.stop(rule.id)
|
||||
return
|
||||
}
|
||||
states[rule.id] = .running(activeConnections: 0)
|
||||
} catch {
|
||||
guard desired.contains(rule.id) else {
|
||||
states[rule.id] = .stopped
|
||||
await disconnectHostIfIdle(rule.hostID)
|
||||
return
|
||||
}
|
||||
if let sshError = error as? TermindSSHError,
|
||||
sshError.isRetryableForwardingFailure
|
||||
{
|
||||
if let session = sessions.removeValue(forKey: rule.hostID) {
|
||||
await session.disconnect()
|
||||
}
|
||||
if reconnectTasks[rule.hostID] == nil {
|
||||
scheduleReconnect(
|
||||
rule.hostID, attempt: 1, message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
} else {
|
||||
desired.remove(rule.id)
|
||||
states[rule.id] = .failed(error.localizedDescription)
|
||||
await disconnectHostIfIdle(rule.hostID)
|
||||
public func reconcileConfiguration() {
|
||||
for context in Array(connections.values) {
|
||||
guard
|
||||
context.route.allSatisfy({ old in
|
||||
hosts.host(old.id).map { old.hasSameConnectionRoute(as: $0) } == true
|
||||
})
|
||||
else {
|
||||
stop(activeIDs(context))
|
||||
continue
|
||||
}
|
||||
}
|
||||
remove(presentations.keys.filter { hosts.portForwarding($0) == nil })
|
||||
}
|
||||
|
||||
private func disconnectHostIfIdle(_ hostID: Host.ID) async {
|
||||
let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID }
|
||||
guard !hasActiveRule else { return }
|
||||
let session = sessions.removeValue(forKey: hostID)
|
||||
connectionTasks[hostID]?.cancel()
|
||||
connectionTasks[hostID] = nil
|
||||
reconnectTasks[hostID]?.cancel()
|
||||
reconnectTasks[hostID] = nil
|
||||
reconnectAttempts[hostID] = nil
|
||||
connectedAt[hostID] = nil
|
||||
if let session { await session.disconnect() }
|
||||
public func submitAuthentication(
|
||||
for id: PortForwardingRule.ID, promptID: UUID,
|
||||
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
|
||||
) {
|
||||
guard generations[id] != nil, let context = presentations[id],
|
||||
let prompt = context.authenticationPrompt, prompt.id == promptID,
|
||||
var host = hosts.host(prompt.host.id)
|
||||
else { return }
|
||||
let identity = identityID.flatMap(hosts.identity)
|
||||
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
if prompt.requiresPassphrase, var staged = context.submissions[host.id] {
|
||||
guard let passphrase, !passphrase.isEmpty else { return }
|
||||
staged.passphrase = passphrase
|
||||
context.submissions[host.id] = staged
|
||||
} else {
|
||||
let chosenKey = identity?.keyID ?? keyID
|
||||
if save {
|
||||
savingAuthentication = context
|
||||
defer { savingAuthentication = nil }
|
||||
do {
|
||||
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
|
||||
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
|
||||
host.identityID = saved.id
|
||||
guard hosts.saveHost(host) else {
|
||||
throw TermindSSHError.library(hosts.storageError ?? String(localized: "Unable to Save"))
|
||||
}
|
||||
context.route = hosts.route(for: hosts.host(context.hostID) ?? host)
|
||||
} catch {
|
||||
var failed = prompt
|
||||
failed.failureMessage = error.localizedDescription
|
||||
context.setPrompt(failed)
|
||||
return
|
||||
}
|
||||
}
|
||||
context.submissions[host.id] = SSHAuthenticationSubmission(
|
||||
username: name, password: password, passphrase: passphrase, keyID: chosenKey, identityID: identity?.id)
|
||||
}
|
||||
context.setPrompt(nil)
|
||||
context.journey.resume()
|
||||
open(context)
|
||||
}
|
||||
|
||||
private func makeSession(for hostID: Host.ID) -> SSHForwardingSession {
|
||||
SSHForwardingSession(
|
||||
connectionHandler: { [weak self] id, count in
|
||||
public func acceptHostKey(for id: UUID, requestID: UUID, observation: SSHHostKeyObservation, remember: Bool) {
|
||||
guard generations[id] != nil, let context = presentations[id],
|
||||
context.hostKeyRequestID == requestID,
|
||||
context.connection.pendingHostKey?.observation == observation
|
||||
else { return }
|
||||
if remember {
|
||||
context.connection.trustHostKey(observation, using: hosts)
|
||||
} else {
|
||||
_ = context.connection.useTemporaryHostKey(observation)
|
||||
}
|
||||
update(context, state: .connecting)
|
||||
}
|
||||
|
||||
private func activeIDs(_ context: PortForwardingConnection) -> [UUID] {
|
||||
generations.keys.filter { presentations[$0] === context }
|
||||
}
|
||||
|
||||
private func isCurrent(_ context: PortForwardingConnection, attempt: UUID) -> Bool {
|
||||
connections[context.hostID] === context && context.attemptID == attempt && !activeIDs(context).isEmpty
|
||||
}
|
||||
|
||||
private func update(_ context: PortForwardingConnection, state: PortForwardingRuntimeState) {
|
||||
for id in activeIDs(context) { states[id] = state }
|
||||
}
|
||||
|
||||
private func open(_ context: PortForwardingConnection) {
|
||||
guard let host = hosts.host(context.hostID), !activeIDs(context).isEmpty else { return }
|
||||
let attempt = UUID()
|
||||
context.attemptID = attempt
|
||||
context.journey.begin(
|
||||
host: host, route: hosts.route(for: host), purpose: .portForwarding,
|
||||
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
|
||||
update(context, state: .preparing)
|
||||
let session = makeSession(
|
||||
{ [weak self, weak context] id, count in
|
||||
Task { @MainActor in
|
||||
guard let self, self.desired.contains(id) else { return }
|
||||
guard let self, let context, self.isCurrent(context, attempt: attempt),
|
||||
self.generations[id] != nil, self.presentations[id] === context,
|
||||
case .running = self.states[id]
|
||||
else { return }
|
||||
self.states[id] = .running(activeConnections: count)
|
||||
}
|
||||
},
|
||||
failureHandler: { [weak self] message in
|
||||
Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func handleConnectionFailure(_ hostID: Host.ID, message: String) {
|
||||
guard reconnectTasks[hostID] == nil else { return }
|
||||
let ids = desiredRuleIDs(for: hostID)
|
||||
guard !ids.isEmpty else { return }
|
||||
sessions[hostID] = nil
|
||||
connectionTasks[hostID]?.cancel()
|
||||
connectionTasks[hostID] = nil
|
||||
let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true
|
||||
let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1
|
||||
scheduleReconnect(hostID, attempt: attempt, message: message)
|
||||
}
|
||||
|
||||
private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) {
|
||||
let delays: [TimeInterval] = [1, 2, 5, 10, 30]
|
||||
let delay = delays[min(attempt - 1, delays.count - 1)]
|
||||
let nextRetry = Date().addingTimeInterval(delay)
|
||||
reconnectAttempts[hostID] = attempt
|
||||
for id in desiredRuleIDs(for: hostID) {
|
||||
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
|
||||
}
|
||||
reconnectTasks[hostID] = Task { [weak self] in
|
||||
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
|
||||
guard let self else { return }
|
||||
await self.reconnect(hostID, attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnect(_ hostID: Host.ID, attempt: Int) async {
|
||||
reconnectTasks[hostID] = nil
|
||||
let ids = desiredRuleIDs(for: hostID)
|
||||
guard !ids.isEmpty, let host = hosts.host(hostID) else { return }
|
||||
let session = makeSession(for: hostID)
|
||||
do {
|
||||
let configuration = try hosts.sshConnectionConfiguration(for: host)
|
||||
try await session.connect(configuration)
|
||||
guard !desiredRuleIDs(for: hostID).isEmpty else {
|
||||
await session.disconnect()
|
||||
return
|
||||
}
|
||||
sessions[hostID] = session
|
||||
connectedAt[hostID] = Date()
|
||||
for id in desiredRuleIDs(for: hostID) {
|
||||
guard let rule = hosts.portForwarding(id) else { continue }
|
||||
states[id] = .starting
|
||||
try await session.start(rule)
|
||||
states[id] = .running(activeConnections: 0)
|
||||
}
|
||||
} catch {
|
||||
sessions[hostID] = nil
|
||||
await session.disconnect()
|
||||
if let sshError = error as? TermindSSHError,
|
||||
!sshError.isRetryableForwardingFailure
|
||||
{
|
||||
for id in desiredRuleIDs(for: hostID) {
|
||||
desired.remove(id)
|
||||
states[id] = .failed(error.localizedDescription)
|
||||
{ [weak self, weak context] message in
|
||||
Task { @MainActor in
|
||||
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
|
||||
self.fail(context, message: message)
|
||||
}
|
||||
})
|
||||
context.session = session
|
||||
context.task = Task { [weak self, weak context] in
|
||||
guard let self, let context else { return }
|
||||
do {
|
||||
let preparation = try hosts.prepareSSHConnection(
|
||||
for: host, submissions: context.submissions,
|
||||
temporaryHostKeys: context.connection.temporaryHostKeys)
|
||||
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
|
||||
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
||||
update(context, state: .connecting)
|
||||
try await session.connect(
|
||||
SSHConnectionRequest(
|
||||
configuration: configuration,
|
||||
authorizeHostKey: { [weak self, weak context] challenge in
|
||||
guard let self, let context else { throw CancellationError() }
|
||||
return await self.authorize(challenge, in: context, attempt: attempt)
|
||||
},
|
||||
keyboardInteractive: { [weak self, weak context] challenge in
|
||||
guard let self, let context else { throw CancellationError() }
|
||||
return try await self.answer(challenge, in: context, attempt: attempt)
|
||||
}),
|
||||
progress: { [weak self, weak context] event in
|
||||
Task { @MainActor in
|
||||
guard let self, let context, self.isCurrent(context, attempt: attempt) else { return }
|
||||
context.journey.receive(event)
|
||||
}
|
||||
})
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
||||
context.task = nil
|
||||
context.connected = true
|
||||
context.submissions.removeAll()
|
||||
context.connection.temporaryHostKeys.removeAll()
|
||||
for id in activeIDs(context) {
|
||||
if let rule = hosts.portForwarding(id) { startRule(rule, in: context) }
|
||||
}
|
||||
} catch {
|
||||
await session.disconnect()
|
||||
guard isCurrent(context, attempt: attempt) else { return }
|
||||
context.task = nil
|
||||
context.attemptID = nil
|
||||
context.session = nil
|
||||
if let ssh = error as? TermindSSHError,
|
||||
ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication
|
||||
{
|
||||
requestAuthentication(context, error: ssh)
|
||||
} else {
|
||||
fail(context, message: error.localizedDescription)
|
||||
}
|
||||
reconnectAttempts[hostID] = nil
|
||||
} else {
|
||||
scheduleReconnect(
|
||||
hostID, attempt: attempt + 1, message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] {
|
||||
desired.filter { hosts.portForwarding($0)?.hostID == hostID }
|
||||
private func authorize(
|
||||
_ challenge: SSHHostKeyChallenge, in context: PortForwardingConnection, attempt: UUID
|
||||
) async -> SSHHostKeyDecision {
|
||||
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
|
||||
let requestID = UUID()
|
||||
context.hostKeyRequestID = requestID
|
||||
defer {
|
||||
if context.hostKeyRequestID == requestID { context.hostKeyRequestID = nil }
|
||||
}
|
||||
update(context, state: .awaitingTrust)
|
||||
let decision = await context.connection.authorizeHostKey(challenge, using: hosts)
|
||||
guard isCurrent(context, attempt: attempt), !Task.isCancelled else { return .reject }
|
||||
update(context, state: .connecting)
|
||||
return decision
|
||||
}
|
||||
|
||||
private func answer(
|
||||
_ challenge: SSHKeyboardInteractiveChallenge, in context: PortForwardingConnection, attempt: UUID
|
||||
) async throws -> [String] {
|
||||
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
||||
try Task.checkCancellation()
|
||||
update(context, state: .awaitingAuthentication)
|
||||
let answers = try await context.connection.answerKeyboardInteractive(challenge)
|
||||
guard isCurrent(context, attempt: attempt) else { throw CancellationError() }
|
||||
update(context, state: .connecting)
|
||||
return answers
|
||||
}
|
||||
|
||||
private func requestAuthentication(_ context: PortForwardingConnection, error: TermindSSHError) {
|
||||
guard let host = hosts.host(error.routeHostID ?? context.hostID) else {
|
||||
fail(context, message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
let staged = context.submissions[host.id]
|
||||
let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host)
|
||||
let keyID = staged?.keyID ?? identity?.keyID
|
||||
let passphrase = keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true && staged?.passphrase == nil
|
||||
context.setPrompt(
|
||||
SSHAuthenticationPrompt(
|
||||
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
|
||||
selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase,
|
||||
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity))
|
||||
if error.isServerAuthenticationChallenge {
|
||||
context.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
|
||||
} else if passphrase {
|
||||
context.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
|
||||
} else {
|
||||
context.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
|
||||
}
|
||||
update(context, state: .awaitingAuthentication)
|
||||
}
|
||||
|
||||
private func startRule(_ rule: PortForwardingRule, in context: PortForwardingConnection) {
|
||||
guard let token = generations[rule.id], let session = context.session else { return }
|
||||
states[rule.id] = .starting
|
||||
let previous = ruleTasks[rule.id]
|
||||
ruleTasks[rule.id] = Task { [weak self] in
|
||||
if let previous { await previous.value }
|
||||
guard let self, generations[rule.id] == token else { return }
|
||||
do {
|
||||
try await session.start(rule)
|
||||
guard generations[rule.id] == token else { return }
|
||||
states[rule.id] = .running(activeConnections: 0)
|
||||
context.journey.succeed()
|
||||
} catch {
|
||||
guard generations[rule.id] == token else { return }
|
||||
generations[rule.id] = nil
|
||||
states[rule.id] = .failed(error.localizedDescription)
|
||||
if activeIDs(context).isEmpty {
|
||||
retire(context)
|
||||
context.journey.fail(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ context: PortForwardingConnection, message: String) {
|
||||
for id in activeIDs(context) {
|
||||
generations[id] = nil
|
||||
states[id] = .failed(message)
|
||||
}
|
||||
retire(context)
|
||||
context.journey.fail(message)
|
||||
}
|
||||
|
||||
private func retire(_ context: PortForwardingConnection) {
|
||||
context.attemptID = nil
|
||||
context.task?.cancel()
|
||||
context.task = nil
|
||||
context.connected = false
|
||||
context.setPrompt(nil)
|
||||
context.hostKeyRequestID = nil
|
||||
context.submissions.removeAll()
|
||||
context.connection.disconnect()
|
||||
let session = context.session
|
||||
context.session = nil
|
||||
if let session { Task { await session.disconnect() } }
|
||||
if connections[context.hostID] === context { connections[context.hostID] = nil }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
public actor SSHForwardingSession {
|
||||
public protocol SSHForwardingSessionProtocol: Actor {
|
||||
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
|
||||
func start(_ rule: PortForwardingRule) async throws
|
||||
func stop(_ id: PortForwardingRule.ID) async
|
||||
func disconnect() async
|
||||
}
|
||||
|
||||
public actor SSHForwardingSession: SSHForwardingSessionProtocol {
|
||||
public typealias ConnectionHandler = @Sendable (PortForwardingRule.ID, Int) -> Void
|
||||
public typealias FailureHandler = @Sendable (String) -> Void
|
||||
|
||||
@@ -23,8 +30,8 @@ public actor SSHForwardingSession {
|
||||
try await connect(SSHConnectionRequest(configuration: configuration))
|
||||
}
|
||||
|
||||
public func connect(_ request: SSHConnectionRequest) async throws {
|
||||
try await worker.connect(request)
|
||||
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler? = nil) async throws {
|
||||
try await worker.connect(request, progress: progress)
|
||||
}
|
||||
|
||||
public func start(_ rule: PortForwardingRule) async throws {
|
||||
@@ -93,9 +100,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
self.failureHandler = failureHandler
|
||||
}
|
||||
|
||||
public func connect(_ request: SSHConnectionRequest) async throws {
|
||||
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws {
|
||||
await queue.perform { self.cleanup() }
|
||||
let opened = try await SSHSessionWorker.openAuthenticatedSession(using: request, timeout: 20)
|
||||
let opened = try await SSHSessionWorker.openAuthenticatedSession(
|
||||
using: request, timeout: 20, progress: progress)
|
||||
do {
|
||||
await queue.perform {
|
||||
self.routeTransport = opened.transport
|
||||
|
||||
+35
-9
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -40,23 +40,6 @@ struct MobileRootView: View {
|
||||
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
|
||||
MobileProView(pro: app.pro)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { app.keyboardAuthentication.current != nil && app.keyboardAuthenticationSceneID == scene.id },
|
||||
set: { _ in }
|
||||
)
|
||||
) {
|
||||
if let challenge = app.keyboardAuthentication.current {
|
||||
SSHKeyboardInteractiveForm(
|
||||
challenge: challenge,
|
||||
submit: { app.keyboardAuthentication.respond(challenge.id, answers: $0) },
|
||||
cancel: { app.keyboardAuthentication.cancel(challenge.id) }
|
||||
)
|
||||
.id(challenge.id)
|
||||
.padding(24)
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
.sheet(item: $scene.renamingWorkspace) { workspace in
|
||||
MobileWorkspaceRenameView(workspace: workspace) { title in
|
||||
app.renameWorkspace(workspace.id, to: title)
|
||||
|
||||
@@ -24,16 +24,4 @@ struct MobileWorkspaceConnectionServices {
|
||||
) async throws -> any SFTPClientProtocol
|
||||
var writeSSH: (SSHSessionActor, Data) async -> Void
|
||||
|
||||
func withKeyboardInteractive(_ handler: @escaping SSHKeyboardInteractiveHandler) -> Self {
|
||||
var services = self
|
||||
services.connectSSH = { session, request, integration, columns, rows, progress in
|
||||
try await self.connectSSH(
|
||||
session, request.withKeyboardInteractiveFallback(handler), integration, columns, rows, progress
|
||||
)
|
||||
}
|
||||
services.connectSFTP = { request, progress, failure in
|
||||
try await self.connectSFTP(request.withKeyboardInteractiveFallback(handler), progress, failure)
|
||||
}
|
||||
return services
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,27 +10,20 @@ struct MobileHostEditor: View {
|
||||
private var library: HostLibraryModel { app.library }
|
||||
let original: Host
|
||||
@State private var draft: Host
|
||||
private let originalForwardings: [PortForwardingRule]
|
||||
@State private var draftForwardings: [PortForwardingRule]
|
||||
@State private var editingForwarding: PortForwardingRule?
|
||||
|
||||
init(app: MobileAppModel, host: Host) {
|
||||
self.app = app
|
||||
let library = app.library
|
||||
original = host
|
||||
_draft = State(initialValue: host)
|
||||
originalForwardings = library.portForwardings(for: host.id)
|
||||
_draftForwardings = State(initialValue: library.portForwardings(for: host.id))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MobileEditorSheet(
|
||||
title: String(localized: "Host"), isDirty: draft != original || draftForwardings != originalForwardings,
|
||||
canSave: MobileHostEditorPolicy.canSave(draft, forwardings: draftForwardings),
|
||||
onCancel: cancel,
|
||||
title: String(localized: "Host"), isDirty: draft != original,
|
||||
canSave: MobileHostEditorPolicy.canSave(draft),
|
||||
save: {
|
||||
if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host }
|
||||
_ = library.upsert(draft, replacingPortForwardings: draftForwardings)
|
||||
library.upsert(draft)
|
||||
if let error = library.storageError { throw MobileEditorError.message(error) }
|
||||
},
|
||||
content: {
|
||||
@@ -89,58 +82,6 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Port Forwarding") {
|
||||
ForEach(draftForwardings) { rule in
|
||||
let runtime = app.forwardings.state(for: rule.id)
|
||||
let state = forwardingState(for: rule, runtimeIsActive: runtime.isActive)
|
||||
let isRunning = state.isEnabled || state.isRuntimeActive
|
||||
HStack {
|
||||
Button {
|
||||
editingForwarding = rule
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text(rule.label.isEmpty ? rule.bindAddress : rule.label)
|
||||
Text(
|
||||
state.isWaitingForSave
|
||||
? String(localized: "Waiting for Save") : runtime.label
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "arrow.left.arrow.right").accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(
|
||||
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
|
||||
systemImage: isRunning ? "stop" : "play"
|
||||
) {
|
||||
app.forwardings.setEnabled(!isRunning, for: rule.id)
|
||||
}.disabled(!state.canToggle)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Duplicate", systemImage: "plus.square.on.square") {
|
||||
var copy = rule
|
||||
copy.id = UUID()
|
||||
draftForwardings.append(copy)
|
||||
}
|
||||
.disabled(!state.canEdit)
|
||||
Button("Delete", systemImage: "trash", role: .destructive) {
|
||||
app.forwardings.discardPending([rule.id])
|
||||
draftForwardings.removeAll { $0.id == rule.id }
|
||||
}
|
||||
.disabled(!state.canEdit)
|
||||
}
|
||||
}
|
||||
Button("Add Forwarding", systemImage: "plus") {
|
||||
editingForwarding = PortForwardingRule(hostID: draft.id)
|
||||
}
|
||||
Text(
|
||||
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Color") {
|
||||
ColorTagPicker(selection: $draft.colorTag, layout: .touch)
|
||||
}
|
||||
@@ -152,22 +93,6 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.sheet(item: $editingForwarding) { rule in
|
||||
MobileForwardingEditor(
|
||||
app: app, host: draft, rule: rule,
|
||||
isNew: !draftForwardings.contains { $0.id == rule.id }
|
||||
) { updated in
|
||||
if let index = draftForwardings.firstIndex(where: { $0.id == updated.id }) {
|
||||
draftForwardings[index] = updated
|
||||
} else {
|
||||
draftForwardings.append(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
app.forwardings.discardPending(draftForwardings.map(\.id))
|
||||
}
|
||||
|
||||
private var proxyKind: Binding<HostProxy.ProtocolKind?> {
|
||||
@@ -205,22 +130,13 @@ struct MobileHostEditor: View {
|
||||
library.identities.filter(library.isProxyEligibleOnThisDevice)
|
||||
}
|
||||
|
||||
private func forwardingState(
|
||||
for rule: PortForwardingRule, runtimeIsActive: Bool
|
||||
) -> PortForwardingEditorPolicy {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: rule,
|
||||
savedRule: app.forwardingLibrary.portForwarding(rule.id), savedHost: library.host(draft.id),
|
||||
isEnabled: app.forwardings.isEnabled(rule.id), isRuntimeActive: runtimeIsActive)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum MobileHostEditorPolicy {
|
||||
static func canSave(_ host: Host, forwardings: [PortForwardingRule]) -> Bool {
|
||||
static func canSave(_ host: Host) -> Bool {
|
||||
!host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(host.port)
|
||||
&& (host.proxy?.isValid ?? true)
|
||||
&& forwardings.allSatisfy(\.isValid)
|
||||
}
|
||||
|
||||
static func selectProxy(
|
||||
|
||||
@@ -2,75 +2,38 @@ import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// Rules belong to the host draft and commit in its one Vault transaction.
|
||||
struct MobileForwardingEditor: View {
|
||||
let app: MobileAppModel
|
||||
let host: Host
|
||||
let original: PortForwardingRule
|
||||
let isNew: Bool
|
||||
let save: (PortForwardingRule) -> Void
|
||||
@State private var draft: PortForwardingRule
|
||||
|
||||
init(
|
||||
app: MobileAppModel, host: Host, rule: PortForwardingRule, isNew: Bool,
|
||||
save: @escaping (PortForwardingRule) -> Void
|
||||
) {
|
||||
init(app: MobileAppModel, rule: PortForwardingRule, isNew: Bool) {
|
||||
self.app = app
|
||||
self.host = host
|
||||
original = rule
|
||||
self.isNew = isNew
|
||||
self.save = save
|
||||
_draft = State(initialValue: rule)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
PortForwardingEditorPolicy.normalized(draft) != nil && app.library.host(draft.hostID) != nil
|
||||
&& !app.forwardings.state(for: draft.id).isActive
|
||||
&& (isNew || app.forwardingLibrary.portForwarding(draft.id) != nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MobileEditorSheet(
|
||||
title: isNew ? String(localized: "New Forwarding") : String(localized: "Edit Forwarding"),
|
||||
isDirty: draft != original || (isNew && state.isEnabled),
|
||||
canSave: draft.isValid && state.canEdit,
|
||||
onCancel: cancel,
|
||||
save: { save(draft) },
|
||||
isDirty: draft != original, canSave: canSave,
|
||||
save: {
|
||||
guard canSave, let rule = PortForwardingEditorPolicy.normalized(draft) else { return }
|
||||
try app.forwardingLibrary.save(rule)
|
||||
},
|
||||
content: {
|
||||
Section {
|
||||
Toggle("Enabled", isOn: enabled)
|
||||
.disabled(!state.canToggle)
|
||||
LabeledContent(
|
||||
"Status",
|
||||
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : runtime.label
|
||||
)
|
||||
if state.isWaitingForSave {
|
||||
Text("This forwarding will start after the host is saved.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
PortForwardingFields(rule: $draft)
|
||||
.disabled(!state.canEdit)
|
||||
PortForwardingHostPicker(rule: $draft, hosts: app.forwardingLibrary.hosts)
|
||||
.disabled(app.forwardings.state(for: draft.id).isActive)
|
||||
PortForwardingFields(rule: $draft).disabled(app.forwardings.state(for: draft.id).isActive)
|
||||
})
|
||||
}
|
||||
|
||||
private var runtime: PortForwardingRuntimeState { app.forwardings.state(for: draft.id) }
|
||||
|
||||
private var state: PortForwardingEditorPolicy {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: draft,
|
||||
savedRule: app.forwardingLibrary.portForwarding(draft.id), savedHost: app.library.host(host.id),
|
||||
isEnabled: app.forwardings.isEnabled(draft.id), isRuntimeActive: runtime.isActive)
|
||||
}
|
||||
|
||||
private var enabled: Binding<Bool> {
|
||||
Binding(
|
||||
get: { state.isEnabled || state.isRuntimeActive },
|
||||
set: { value in
|
||||
app.forwardings.setEnabled(value, for: draft.id)
|
||||
})
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
if isNew { app.forwardings.discardPending([draft.id]) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
import UIKit
|
||||
|
||||
struct MobileForwardingView: View {
|
||||
let app: MobileAppModel
|
||||
let reviewScene: MobileSceneModel?
|
||||
@State private var query: String
|
||||
@State private var editing: PortForwardingRule?
|
||||
@State private var showing: UUID?
|
||||
@State private var removing: PortForwardingRule?
|
||||
@State private var error: String?
|
||||
|
||||
init(app: MobileAppModel, initialQuery: String = "", reviewScene: MobileSceneModel? = nil) {
|
||||
self.app = app
|
||||
self.reviewScene = reviewScene
|
||||
_query = State(initialValue: initialQuery)
|
||||
}
|
||||
|
||||
private var rules: [PortForwardingRule] { app.forwardingLibrary.search(query) }
|
||||
|
||||
var body: some View {
|
||||
MobileCardPage {
|
||||
if app.forwardingLibrary.rules.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Port Forwarding", systemImage: "arrow.left.arrow.right",
|
||||
description: Text("Create a forwarding rule, then start it manually."))
|
||||
} else if rules.isEmpty {
|
||||
ContentUnavailableView.search(text: query)
|
||||
}
|
||||
MobileCardGrid {
|
||||
ForEach(rules) { rule in
|
||||
card(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Port Forwarding")
|
||||
.searchable(text: $query.singleLineText)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("New Forwarding", systemImage: "plus") { editing = PortForwardingRule(hostID: UUID()) }
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $showing) { id in
|
||||
MobileForwardingDetailView(app: app, ruleID: id)
|
||||
.mobileReviewPage("vault.portForwarding.detail", scene: reviewScene)
|
||||
}
|
||||
.sheet(item: $editing) { rule in
|
||||
MobileForwardingEditor(app: app, rule: rule, isNew: app.forwardingLibrary.portForwarding(rule.id) == nil)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Forwarding Rules?",
|
||||
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
guard let removing else { return }
|
||||
do { try app.forwardingLibrary.delete(removing.id) } catch { self.error = error.localizedDescription }
|
||||
self.removing = nil
|
||||
}
|
||||
}
|
||||
.alert("Operation Failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func card(_ rule: PortForwardingRule) -> some View {
|
||||
let runtime = app.forwardings.state(for: rule.id)
|
||||
return MobileActionCard {
|
||||
showing = rule.id
|
||||
} content: {
|
||||
MobileForwardingCardContent(
|
||||
rule: rule, hostName: app.library.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"),
|
||||
runtime: runtime)
|
||||
} actions: {
|
||||
if runtime.isActive {
|
||||
Button("Stop", systemImage: "stop") { app.forwardings.stop(rule.id) }
|
||||
} else {
|
||||
Button(runtime.isFailed ? "Retry" : "Start", systemImage: "play") { start(rule) }.disabled(
|
||||
!canStart(rule))
|
||||
}
|
||||
Button("Edit", systemImage: "pencil") { editing = rule }.disabled(runtime.isActive)
|
||||
Button("Duplicate", systemImage: "plus.square.on.square") { duplicate(rule) }
|
||||
Button("Copy Address", systemImage: "doc.on.doc") { UIPasteboard.general.string = rule.bindAddress }
|
||||
Button("Copy SSH Command") { UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule) }
|
||||
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
|
||||
Divider()
|
||||
Button("Delete", systemImage: "trash", role: .destructive) { removing = rule }
|
||||
}
|
||||
}
|
||||
private func canStart(_ rule: PortForwardingRule) -> Bool {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: rule, savedHost: app.library.host(rule.hostID),
|
||||
isRuntimeActive: app.forwardings.state(for: rule.id).isActive
|
||||
).canStart
|
||||
}
|
||||
private func start(_ rule: PortForwardingRule) {
|
||||
showing = rule.id
|
||||
app.forwardings.start(rule.id)
|
||||
}
|
||||
private func duplicate(_ rule: PortForwardingRule) {
|
||||
var copy = rule
|
||||
copy.id = UUID()
|
||||
if !copy.label.isEmpty { copy.label += String(localized: " Copy") }
|
||||
do {
|
||||
try app.forwardingLibrary.save(copy)
|
||||
editing = copy
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileForwardingCardContent: View {
|
||||
let rule: PortForwardingRule
|
||||
let hostName: String
|
||||
let runtime: PortForwardingRuntimeState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.displayName(hostName: hostName)).font(.headline).lineLimit(1)
|
||||
Text(rule.summary).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
.lineLimit(1).truncationMode(.middle)
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
MobileCardChip(text: rule.kind.shortDisplayName)
|
||||
Text(hostName).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
MobileCardChip(text: runtime.label, symbol: runtime.isActive ? "circle.fill" : "circle")
|
||||
}
|
||||
}.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileForwardingDetailView: View {
|
||||
let app: MobileAppModel
|
||||
let ruleID: UUID
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var editing: PortForwardingRule?
|
||||
|
||||
private var rule: PortForwardingRule? { app.forwardingLibrary.portForwarding(ruleID) }
|
||||
private var runtime: PortForwardingRuntimeState { app.forwardings.state(for: ruleID) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let rule {
|
||||
if let context = app.forwardings.connection(for: ruleID), context.journey.isPresented {
|
||||
journey(context)
|
||||
} else {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status", value: runtime.label)
|
||||
}
|
||||
Section("Port Forwarding") {
|
||||
LabeledContent(
|
||||
"Host",
|
||||
value: app.library.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"))
|
||||
LabeledContent("Direction", value: rule.kind.displayName)
|
||||
Text(rule.summary).font(.body.monospaced()).textSelection(.enabled)
|
||||
Button("Copy Address") { UIPasteboard.general.string = rule.bindAddress }
|
||||
Button("Copy SSH Command") {
|
||||
UIPasteboard.general.string = app.forwardingLibrary.sshCommand(for: rule)
|
||||
}
|
||||
.disabled(app.forwardingLibrary.sshCommand(for: rule) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("The forwarding rule is unavailable.", systemImage: "arrow.left.arrow.right")
|
||||
}
|
||||
}
|
||||
.navigationTitle(rule.map { $0.displayName(hostName: app.library.host($0.hostID)?.name) } ?? "")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if runtime.isActive {
|
||||
Button("Stop", systemImage: "stop") { app.forwardings.stop(ruleID) }
|
||||
} else if let rule {
|
||||
Button(
|
||||
runtime.isFailed ? "Retry" : "Start", systemImage: runtime.isFailed ? "arrow.clockwise" : "play"
|
||||
) {
|
||||
app.forwardings.start(ruleID)
|
||||
}.disabled(app.library.host(rule.hostID) == nil || !rule.isValid)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .secondaryAction) {
|
||||
if let rule {
|
||||
Button("Edit", systemImage: "pencil") { editing = rule }.disabled(runtime.isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editing) { rule in MobileForwardingEditor(app: app, rule: rule, isNew: false) }
|
||||
}
|
||||
|
||||
private func journey(_ context: PortForwardingConnection) -> some View {
|
||||
let promptID = context.authenticationPrompt?.id
|
||||
let hostKey = context.connection.pendingHostKey
|
||||
let hostKeyRequestID = context.hostKeyRequestID
|
||||
return MobileConnectionJourneyView(
|
||||
journey: context.journey, library: app.library, authenticationPrompt: context.authenticationPrompt,
|
||||
hostKey: hostKey, reconnectState: nil,
|
||||
submit: { identity, username, password, passphrase, key, save in
|
||||
guard let promptID else { return }
|
||||
app.forwardings.submitAuthentication(
|
||||
for: ruleID, promptID: promptID, identityID: identity,
|
||||
username: username, password: password, passphrase: passphrase, keyID: key, save: save)
|
||||
},
|
||||
accept: { remember in
|
||||
if let hostKey, let hostKeyRequestID {
|
||||
app.forwardings.acceptHostKey(
|
||||
for: ruleID, requestID: hostKeyRequestID, observation: hostKey.observation, remember: remember)
|
||||
}
|
||||
},
|
||||
retry: { app.forwardings.retry(ruleID) }, retryReconnectNow: {}, stopReconnecting: {}, cancel: cancel,
|
||||
keyboardAuthentication: context.connection.keyboardAuthentication, cancelKeyboardAuthentication: cancel)
|
||||
}
|
||||
private func cancel() {
|
||||
app.forwardings.stop(ruleID)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,34 @@ struct MobileCardTests {
|
||||
#expect(Array(MobileCardLayout.visibleTags(["one", "two", "three"])) == ["one", "two"])
|
||||
}
|
||||
|
||||
@Test func forwardingCardsMatchHostHeightWithOrWithoutNames() {
|
||||
for width: CGFloat in [320, 398, 600] {
|
||||
let hostHeight = measuredHeight(of: standardActionCard(tags: ["production"]), width: width)
|
||||
for kind in PortForwardingRule.Kind.allCases {
|
||||
for name in ["", "Database with a long display name that stays on one line"] {
|
||||
let rule = PortForwardingRule(
|
||||
hostID: UUID(), kind: kind, targetHost: "database.production.example.invalid", label: name)
|
||||
let card = MobileActionCard(
|
||||
action: {},
|
||||
content: { MobileForwardingCardContent(rule: rule, hostName: "Production", runtime: .stopped) },
|
||||
actions: { EmptyView() })
|
||||
#expect(abs(measuredHeight(of: card, width: width) - hostHeight) <= 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unnamedForwardingUsesTheHostAndSSHArgument() {
|
||||
for (kind, argument) in [(PortForwardingRule.Kind.local, "-L"), (.remote, "-R"), (.dynamic, "-D")] {
|
||||
var rule = PortForwardingRule(hostID: UUID(), kind: kind)
|
||||
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
|
||||
rule.label = "Database"
|
||||
#expect(rule.displayName(hostName: "AWS") == "Database")
|
||||
rule.label = " "
|
||||
#expect(rule.displayName(hostName: "AWS") == "AWS (\(argument))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func hostSectionsFoldNestedGroupsKeepEmptyGroupsAndLeaveSearchAvailable() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let root = HostGroup(name: "Production")
|
||||
|
||||
@@ -426,7 +426,6 @@ struct MobileUploadBatchTests {
|
||||
let identity = Identity(name: "OTP", username: "user")
|
||||
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
|
||||
let library = HostLibraryModel(hosts: [host], identities: [identity])
|
||||
let queue = SSHKeyboardInteractiveQueue()
|
||||
let client = MobileUploadSFTPClient()
|
||||
let transfers = SFTPTransferCoordinator(store: library) { client }
|
||||
let services = MobileWorkspaceConnectionServices(
|
||||
@@ -443,7 +442,7 @@ struct MobileUploadBatchTests {
|
||||
prompts: [.init(text: "Code", echo: false)]))
|
||||
return client
|
||||
}, writeSSH: { _, _ in }
|
||||
).withKeyboardInteractive(queue.handler)
|
||||
)
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
|
||||
hosts: library, transfers: transfers, connectionServices: services)
|
||||
@@ -454,7 +453,7 @@ struct MobileUploadBatchTests {
|
||||
#expect(workspace.uploadTargetProblem == nil)
|
||||
#expect(workspace.prepareUpload([URL(fileURLWithPath: "/tmp/termind-test-upload")]))
|
||||
#expect(await eventually { workspace.operationError != nil })
|
||||
#expect(queue.current == nil)
|
||||
#expect(workspace.keyboardAuthentication.current == nil)
|
||||
#expect(transfers.sftpTransfers.isEmpty)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileForwardingLifecycleTests {
|
||||
@Test func backgroundExpirationCancelsSharedAuthenticationAndForegroundNeverRestartsIt() async throws {
|
||||
let background = ForwardingBackgroundTask()
|
||||
let app = try MobileAppModel(inMemory: true, background: background)
|
||||
let host = TermindDomain.Host(name: "Server", host: "server.invalid")
|
||||
app.library.upsert(host)
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
try app.forwardingLibrary.save(rule)
|
||||
let firstScene = UUID()
|
||||
let secondScene = UUID()
|
||||
defer {
|
||||
app.forwardings.stopAll()
|
||||
app.removeScene(firstScene)
|
||||
app.removeScene(secondScene)
|
||||
}
|
||||
app.sceneChanged(firstScene, phase: .active)
|
||||
app.sceneChanged(secondScene, phase: .active)
|
||||
app.forwardings.start(rule.id)
|
||||
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
let context = try #require(app.forwardings.connection(for: rule.id))
|
||||
app.sceneChanged(firstScene, phase: .background)
|
||||
#expect(background.expiration == nil)
|
||||
#expect(context.authenticationPrompt != nil)
|
||||
app.sceneChanged(secondScene, phase: .background)
|
||||
let expire = try #require(background.expiration)
|
||||
expire()
|
||||
#expect(context.authenticationPrompt == nil)
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
app.sceneChanged(secondScene, phase: .active)
|
||||
#expect(app.forwardings.connection(for: rule.id) == nil)
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
}
|
||||
|
||||
@Test func syncedRulesAndJumpHostChangesStopAffectedRunsWithoutRestarting() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer { app.forwardings.stopAll() }
|
||||
let jump = TermindDomain.Host(name: "Jump", host: "jump.invalid")
|
||||
let host = TermindDomain.Host(name: "Server", host: "server.invalid", jumpHostID: jump.id)
|
||||
app.library.upsert(jump)
|
||||
app.library.upsert(host)
|
||||
var rule = PortForwardingRule(hostID: host.id)
|
||||
try app.forwardingLibrary.save(rule)
|
||||
app.forwardings.start(rule.id)
|
||||
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
rule.bindPort = 9090
|
||||
app.applyRemoteVault(
|
||||
.init(hosts: [jump, host], portForwardings: [rule]),
|
||||
keys: [VaultRecordKey(kind: .portForwarding, id: rule.id)])
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
#expect(app.forwardings.connection(for: rule.id) == nil)
|
||||
app.forwardings.start(rule.id)
|
||||
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
var changedJump = jump
|
||||
changedJump.port = 2222
|
||||
app.applyRemoteVault(
|
||||
.init(hosts: [changedJump, host], portForwardings: [rule]),
|
||||
keys: [VaultRecordKey(kind: .host, id: jump.id)])
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
#expect(app.forwardings.connection(for: rule.id) == nil)
|
||||
app.forwardings.start(rule.id)
|
||||
try await wait { app.forwardings.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
app.deleteHost(jump.id)
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
#expect(app.forwardings.connection(for: rule.id) == nil)
|
||||
app.outbox.stop()
|
||||
}
|
||||
|
||||
private func wait(_ condition: () -> Bool) async throws {
|
||||
for _ in 0..<400 {
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
throw CocoaError(.coderInvalidValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class ForwardingBackgroundTask: MobileBackgroundTaskManaging {
|
||||
var expiration: (@MainActor () -> Void)?
|
||||
func begin(expiration: @escaping @MainActor () -> Void) { self.expiration = expiration }
|
||||
func finish() { expiration = nil }
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MobileForwardingRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func forwardingUsesVaultCardsAndInPageAuthenticationWithoutASheet(_ language: String) async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
|
||||
app.library.upsert(host)
|
||||
let rule = PortForwardingRule(hostID: host.id, label: "Database", colorTag: .teal)
|
||||
let unnamed = PortForwardingRule(hostID: host.id, bindPort: 15432)
|
||||
for candidate in [
|
||||
rule,
|
||||
PortForwardingRule(hostID: host.id, kind: .remote, bindPort: 9090, label: "Preview", colorTag: .orange),
|
||||
PortForwardingRule(hostID: host.id, kind: .dynamic, bindPort: 1080, label: "SOCKS5"),
|
||||
unnamed,
|
||||
] {
|
||||
try app.forwardingLibrary.save(candidate)
|
||||
}
|
||||
let route = ForwardingRenderRoute()
|
||||
var features = MobileFeatureRegistry()
|
||||
features.register(
|
||||
MobileFeatureRegistration(id: .vault, title: "Vault", symbol: "server.rack") { app, scene in
|
||||
AnyView(
|
||||
MobileForwardingView(app: app, reviewScene: scene)
|
||||
.navigationDestination(
|
||||
isPresented: Binding(get: { route.details }, set: { route.details = $0 })
|
||||
) {
|
||||
MobileForwardingDetailView(app: app, ruleID: route.unnamed ? unnamed.id : rule.id)
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { route.editing }, set: { route.editing = $0 })) {
|
||||
MobileForwardingEditor(app: app, rule: rule, isNew: false)
|
||||
})
|
||||
})
|
||||
let scene = MobileSceneModel(app: app)
|
||||
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = windowScene.keyWindow
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
let container = UIViewController()
|
||||
let controller = UIHostingController(
|
||||
rootView: MobileRootView(app: app, scene: scene, features: features)
|
||||
.environment(\.locale, Locale(identifier: language)).environment(\.scenePhase, .active))
|
||||
container.addChild(controller)
|
||||
container.view.addSubview(controller.view)
|
||||
controller.didMove(toParent: container)
|
||||
window.rootViewController = container
|
||||
window.windowLevel = .alert + 3
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
app.forwardings.stopAll()
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previous?.makeKey()
|
||||
}
|
||||
resize(controller, width: 430, height: 900)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
try capture(controller.view, name: "cards-\(language)")
|
||||
route.editing = true
|
||||
try await Task.sleep(for: .milliseconds(650))
|
||||
try capture(try #require(presented(in: controller)?.view), name: "editor-\(language)")
|
||||
route.editing = false
|
||||
try await Task.sleep(for: .milliseconds(650))
|
||||
route.details = true
|
||||
try await Task.sleep(for: .milliseconds(650))
|
||||
try capture(controller.view, name: "detail-stopped-\(language)")
|
||||
route.unnamed = true
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
try capture(controller.view, name: "detail-unnamed-\(language)")
|
||||
route.details = false
|
||||
try await Task.sleep(for: .milliseconds(650))
|
||||
route.unnamed = false
|
||||
app.forwardings.start(rule.id)
|
||||
for _ in 0..<400 where app.forwardings.connection(for: rule.id)?.authenticationPrompt == nil {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
let context = try #require(app.forwardings.connection(for: rule.id))
|
||||
let prompt = try #require(context.authenticationPrompt)
|
||||
#expect(!route.details)
|
||||
#expect(presented(in: controller) == nil)
|
||||
route.details = true
|
||||
for size in [
|
||||
CGSize(width: 430, height: 900), CGSize(width: 900, height: 430), CGSize(width: 1024, height: 900),
|
||||
] {
|
||||
resize(controller, width: size.width, height: size.height)
|
||||
try await Task.sleep(for: .milliseconds(650))
|
||||
#expect(context.authenticationPrompt?.id == prompt.id)
|
||||
#expect(presented(in: controller) == nil)
|
||||
try capture(controller.view, name: "journey-\(language)-\(Int(size.width))")
|
||||
}
|
||||
route.details = false
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(context.authenticationPrompt?.id == prompt.id)
|
||||
route.details = true
|
||||
controller.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
|
||||
resize(controller, width: 430, height: 900)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
try capture(controller.view, name: "journey-\(language)-large-type")
|
||||
app.forwardings.stop(rule.id)
|
||||
#expect(context.authenticationPrompt == nil)
|
||||
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
||||
}
|
||||
|
||||
private func resize(_ controller: UIViewController, width: CGFloat, height: CGFloat) {
|
||||
let frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
controller.view.window?.frame = frame
|
||||
controller.parent?.view.frame = frame
|
||||
controller.traitOverrides.horizontalSizeClass = width >= 600 ? .regular : .compact
|
||||
controller.view.frame = frame
|
||||
controller.view.setNeedsLayout()
|
||||
controller.view.layoutIfNeeded()
|
||||
}
|
||||
private func presented(in controller: UIViewController) -> UIViewController? {
|
||||
controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first
|
||||
}
|
||||
private func capture(_ view: UIView, name: String) throws {
|
||||
let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in
|
||||
view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
Attachment.record(data, named: "mobile-forwarding-\(name).png")
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-forwarding-ui")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appending(path: "mobile-forwarding-\(name).png"))
|
||||
}
|
||||
}
|
||||
|
||||
@Observable @MainActor private final class ForwardingRenderRoute {
|
||||
var details = false
|
||||
var unnamed = false
|
||||
var editing = false
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,6 @@ struct AppDependencies {
|
||||
let metricsSync: MetricsSyncCoordinator?
|
||||
let vaultSync: VaultSyncCoordinator?
|
||||
let metricsPreferences: (any MetricsConfigurationStoring)?
|
||||
let forwardingDefaults: UserDefaults?
|
||||
let mcpServerSecrets: any SecretStoring
|
||||
|
||||
init(
|
||||
@@ -30,7 +29,6 @@ struct AppDependencies {
|
||||
settingsSync: SettingsSyncCoordinator? = nil,
|
||||
metricsSync: MetricsSyncCoordinator? = nil,
|
||||
metricsPreferences: (any MetricsConfigurationStoring)? = nil,
|
||||
forwardingDefaults: UserDefaults? = nil,
|
||||
mcpServerSecrets: any SecretStoring = InMemorySecretStore()
|
||||
) {
|
||||
self.hostRepository = hostRepository
|
||||
@@ -42,7 +40,6 @@ struct AppDependencies {
|
||||
self.settingsSync = settingsSync
|
||||
self.metricsSync = metricsSync
|
||||
self.metricsPreferences = metricsPreferences
|
||||
self.forwardingDefaults = forwardingDefaults
|
||||
self.mcpServerSecrets = mcpServerSecrets
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -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()
|
||||
|
||||
@@ -115,7 +115,7 @@ struct TermindApp: App {
|
||||
),
|
||||
vaultSync: vaultSync, settingsSync: settingsSync,
|
||||
metricsSync: metricsSync, metricsPreferences: metricsPersistence,
|
||||
forwardingDefaults: .standard, mcpServerSecrets: secrets
|
||||
mcpServerSecrets: secrets
|
||||
),
|
||||
proAccess: proAccess,
|
||||
appReview: .live(),
|
||||
@@ -185,10 +185,6 @@ struct TermindApp: App {
|
||||
didSet {
|
||||
guard let app else { return }
|
||||
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
|
||||
if !didRestoreForwardings {
|
||||
didRestoreForwardings = true
|
||||
app.forwarding.resumeEnabled()
|
||||
}
|
||||
app.serverMetrics.start()
|
||||
metricsActivity.start(app.serverMetrics)
|
||||
metricsMenu.openMainWindow = { [weak app] in app?.mainWindows.openMetrics() }
|
||||
@@ -197,7 +193,6 @@ struct TermindApp: App {
|
||||
}
|
||||
private let metricsActivity = MetricsActivityObserver()
|
||||
private let metricsMenu = MetricsMenuBarController()
|
||||
private var didRestoreForwardings = false
|
||||
private var awaitingTermination = false
|
||||
private var repliedToTermination = false
|
||||
private var quietLaunchObserver: (any NSObjectProtocol)?
|
||||
|
||||
@@ -18,21 +18,3 @@ extension KeyStorage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PortForwardingRule.Kind {
|
||||
nonisolated var displayName: String {
|
||||
switch self {
|
||||
case .local: String(localized: "Local forward (-L)")
|
||||
case .remote: String(localized: "Remote forward (-R)")
|
||||
case .dynamic: String(localized: "Dynamic proxy (-D)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var symbol: String {
|
||||
switch self {
|
||||
case .local: "arrow.right"
|
||||
case .remote: "arrow.left"
|
||||
case .dynamic: "shuffle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ struct HostEditorView: View {
|
||||
case identity
|
||||
case proxyIdentity
|
||||
case group
|
||||
case forwarding(PortForwardingRule)
|
||||
}
|
||||
|
||||
let hostID: Host.ID?
|
||||
@@ -22,7 +21,6 @@ struct HostEditorView: View {
|
||||
@Environment(WindowModel.self) private var window
|
||||
|
||||
@State private var draft = Host(name: "", host: "")
|
||||
@State private var draftForwardings: [PortForwardingRule] = []
|
||||
@State private var route: Route?
|
||||
@State private var saveError: String?
|
||||
@State private var didLoad = false
|
||||
@@ -146,11 +144,6 @@ struct HostEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
PortForwardingEditorSection(
|
||||
host: draft, rules: $draftForwardings,
|
||||
openEditor: { route = .forwarding($0) }
|
||||
)
|
||||
|
||||
Section("Group & Tags") {
|
||||
Picker("Group", selection: groupChoice) {
|
||||
Text("None").tag(Choice<HostGroup.ID>.unset)
|
||||
@@ -227,14 +220,7 @@ struct HostEditorView: View {
|
||||
draft.proxy?.identityID = identity.id
|
||||
draft.proxy?.username = identity.username
|
||||
}
|
||||
case .forwarding(let rule):
|
||||
PortForwardingEditorSheet(
|
||||
forwarding: rule, host: draft,
|
||||
isNew: !draftForwardings.contains { $0.id == rule.id },
|
||||
back: closeNestedEditor,
|
||||
delete: { draftForwardings.removeAll { $0.id == rule.id } },
|
||||
save: upsertForwardingDraft
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,14 +228,6 @@ struct HostEditorView: View {
|
||||
route = nil
|
||||
}
|
||||
|
||||
private func upsertForwardingDraft(_ rule: PortForwardingRule) {
|
||||
if let index = draftForwardings.firstIndex(where: { $0.id == rule.id }) {
|
||||
draftForwardings[index] = rule
|
||||
} else {
|
||||
draftForwardings.append(rule)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picking, and making
|
||||
|
||||
/// What a picker row means: nothing, one of the saved records, or the row
|
||||
@@ -442,13 +420,11 @@ struct HostEditorView: View {
|
||||
&& (draft.sftpSessionInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| draft.sftpSessionInitialPath.hasPrefix("/"))
|
||||
&& app.hostLibrary.validatedRoute(for: draft) != nil
|
||||
&& draftForwardings.allSatisfy(\.isValid)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let hostID, let existing = app.hostLibrary.host(hostID) else { return }
|
||||
draft = existing
|
||||
draftForwardings = app.hostLibrary.portForwardings(for: hostID)
|
||||
}
|
||||
|
||||
private func loadOnce() {
|
||||
@@ -465,11 +441,7 @@ struct HostEditorView: View {
|
||||
}
|
||||
let initialPath = draft.sftpSessionInitialPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
draft.sftpSessionInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath)
|
||||
for index in draftForwardings.indices { draftForwardings[index].hostID = draft.id }
|
||||
if app.hostLibrary.upsert(
|
||||
draft,
|
||||
replacingPortForwardings: draftForwardings
|
||||
) {
|
||||
if app.hostLibrary.saveHost(draft) {
|
||||
closeTab()
|
||||
} else {
|
||||
saveError = app.hostLibrary.storageError ?? String(localized: "The host could not be saved.")
|
||||
@@ -477,7 +449,6 @@ struct HostEditorView: View {
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
app.forwarding.discardPending(draftForwardings.map(\.id))
|
||||
closeTab()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,320 +4,279 @@ import TermindDomain
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
import struct TermindDomain.Host
|
||||
@Observable
|
||||
final class PortForwardingPageState {
|
||||
var query = ""
|
||||
var deleting: Set<UUID> = []
|
||||
var error: String?
|
||||
}
|
||||
|
||||
/// Host-scoped forwarding management embedded in `HostEditorView`.
|
||||
struct PortForwardingEditorSection: View {
|
||||
let host: Host
|
||||
@Binding var rules: [PortForwardingRule]
|
||||
let openEditor: (PortForwardingRule) -> Void
|
||||
struct PortForwardingPageView: ManagedCardPage {
|
||||
typealias SectionID = String
|
||||
typealias Item = PortForwardingRule
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let state: PortForwardingPageState
|
||||
|
||||
var body: some View {
|
||||
forwardingSection
|
||||
var managedSections: [ManagedCardSection<String, PortForwardingRule>] {
|
||||
[
|
||||
ManagedCardSection(
|
||||
id: "forwardings", title: String(localized: "Port Forwarding"),
|
||||
items: app.forwardings.search(state.query), canAcceptDrop: false, showsWhenEmpty: false)
|
||||
]
|
||||
}
|
||||
var managedSelection: ManagedCardSelection<UUID> {
|
||||
ManagedCardSelection(
|
||||
selected: { Set(window.vaultSelection(for: .portForwarding).compactMap(\.forwardingID)) },
|
||||
update: { ids in
|
||||
window.setVaultSelection(
|
||||
app.forwardings.rules.filter { ids.contains($0.id) }.map { .forwarding($0.id) },
|
||||
for: .portForwarding)
|
||||
})
|
||||
}
|
||||
var managedCapabilities: ManagedCardCapabilities<String, UUID> {
|
||||
ManagedCardCapabilities(selection: .multiple, marqueeSelection: true)
|
||||
}
|
||||
var isManagedSourceEmpty: Bool { app.forwardings.rules.isEmpty }
|
||||
|
||||
private var forwardingSection: some View {
|
||||
Section("Port Forwarding") {
|
||||
ForEach(rules) { rule in
|
||||
HostForwardingRow(
|
||||
host: host,
|
||||
rule: rule,
|
||||
edit: { openEditor(rule) },
|
||||
duplicate: { duplicate(rule) },
|
||||
delete: { delete(rule) }
|
||||
)
|
||||
func managedHeader(for section: ManagedCardSection<String, Item>, isDropTarget: Bool) -> some View {
|
||||
CardSectionHeader(title: section.title, count: section.items.count)
|
||||
}
|
||||
func managedCard(for item: Item, context: ManagedCardItemContext<Item>) -> some View {
|
||||
card(item, selected: context.isSelected, select: context.select, extend: context.extendSelection)
|
||||
.contextMenu {
|
||||
if context.actionItems.count > 1 {
|
||||
Button("Stop", systemImage: MenuSymbol.stop) {
|
||||
app.forwarding.stop(
|
||||
context.actionItems.filter { app.forwarding.state(for: $0.id).isActive }.map(\.id))
|
||||
}
|
||||
.disabled(!context.actionItems.contains { app.forwarding.state(for: $0.id).isActive })
|
||||
} else {
|
||||
if app.forwarding.state(for: item.id).isActive {
|
||||
Button("Stop", systemImage: MenuSymbol.stop) { app.forwarding.stop(item.id) }
|
||||
} else {
|
||||
Button(
|
||||
app.forwarding.state(for: item.id).isFailed ? "Retry" : "Start",
|
||||
systemImage: MenuSymbol.start
|
||||
) { start(item) }.disabled(!canStart(item))
|
||||
}
|
||||
Button("Edit", systemImage: MenuSymbol.edit) { open(item) }
|
||||
Button("Duplicate", systemImage: MenuSymbol.duplicate) { duplicate(item) }
|
||||
Button("Copy Address", systemImage: MenuSymbol.copy) { copyToPasteboard(item.bindAddress) }
|
||||
Button("Copy SSH Command") {
|
||||
if let command = app.forwardings.sshCommand(for: item) { copyToPasteboard(command) }
|
||||
}.disabled(app.forwardings.sshCommand(for: item) == nil)
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", systemImage: MenuSymbol.delete, role: .destructive) {
|
||||
state.deleting = Set(context.actionItems.map(\.id))
|
||||
}
|
||||
}
|
||||
FormNavigationAction(title: "Add Forwarding") {
|
||||
openEditor(PortForwardingRule(hostID: host.id))
|
||||
}
|
||||
if !rules.isEmpty {
|
||||
forwardingActions
|
||||
}
|
||||
Text(
|
||||
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
func managedGhost(for item: Item) -> some View { card(item, selected: false, select: {}, extend: {}) }
|
||||
func managedBlankAreaMenu(for section: String?) -> [NativeMenuEntry] {
|
||||
window.creationMenu(for: .portForwarding, locale: .current)
|
||||
}
|
||||
func managedEmptyState() -> some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Port Forwarding", systemImage: "arrow.left.arrow.right")
|
||||
} description: {
|
||||
Text("Create a forwarding rule, then start it manually.")
|
||||
} actions: {
|
||||
Button("New Forwarding") { window.openVaultWork(.forwardingEditor(nil), on: .portForwarding) }
|
||||
}
|
||||
}
|
||||
func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) }
|
||||
var managedPresentation: PortForwardingPagePresentation {
|
||||
PortForwardingPagePresentation(
|
||||
state: state, ids: app.forwardings.rules.map(\.id),
|
||||
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
|
||||
delete: {
|
||||
do { try app.forwardings.delete(state.deleting) } catch { state.error = error.localizedDescription }
|
||||
state.deleting = []
|
||||
})
|
||||
}
|
||||
|
||||
private var forwardingActions: some View {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
Spacer()
|
||||
Button("Enable All", action: enableAll)
|
||||
.disabled(enableableRules.isEmpty)
|
||||
Button("Disable All", action: disableAll)
|
||||
.disabled(runningRules.isEmpty)
|
||||
private func card(_ rule: Item, selected: Bool, select: @escaping () -> Void, extend: @escaping () -> Void)
|
||||
-> some View
|
||||
{
|
||||
let runtime = app.forwarding.state(for: rule.id)
|
||||
let activate = {
|
||||
if app.forwarding.state(for: rule.id).isActive { app.forwarding.stop(rule.id) } else { start(rule) }
|
||||
}
|
||||
return CardTile(
|
||||
selectionID: rule.id, isSelected: selected,
|
||||
select: select, extendSelection: extend, activate: activate, activatesOnReturn: true
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack {
|
||||
CardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? TermindColor.accent)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(rule.displayName(hostName: app.hostLibrary.host(rule.hostID)?.name))
|
||||
.font(.headline).lineLimit(1)
|
||||
Text(rule.summary).font(.caption).foregroundStyle(.secondary)
|
||||
.lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
HStack(spacing: Metrics.Space.xs) {
|
||||
CardChip(text: rule.kind.shortDisplayName)
|
||||
Text(app.hostLibrary.host(rule.hostID)?.name ?? String(localized: "Host Unavailable"))
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
CardChip(
|
||||
text: runtime.label, symbol: runtime.isActive ? "circle.fill" : "circle",
|
||||
tint: runtime.isActive ? .green : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var enableableRules: [PortForwardingRule] {
|
||||
rules.filter { !app.forwarding.isEnabled($0.id) }
|
||||
private func canStart(_ rule: Item) -> Bool {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: rule, savedHost: app.hostLibrary.host(rule.hostID),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive
|
||||
).canStart
|
||||
}
|
||||
|
||||
private var runningRules: [PortForwardingRule] {
|
||||
rules.filter { app.forwarding.isEnabled($0.id) || app.forwarding.state(for: $0.id).isActive }
|
||||
private func open(_ rule: Item) { window.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding) }
|
||||
private func start(_ rule: Item) {
|
||||
guard canStart(rule) else { return }
|
||||
open(rule)
|
||||
app.forwarding.start(rule.id)
|
||||
}
|
||||
|
||||
private func duplicate(_ rule: PortForwardingRule) {
|
||||
private func duplicate(_ rule: Item) {
|
||||
var copy = rule
|
||||
copy.id = UUID()
|
||||
if !copy.label.isEmpty { copy.label += String(localized: " Copy") }
|
||||
rules.append(copy)
|
||||
}
|
||||
|
||||
private func delete(_ rule: PortForwardingRule) {
|
||||
app.forwarding.discardPending([rule.id])
|
||||
rules.removeAll { $0.id == rule.id }
|
||||
}
|
||||
|
||||
private func enableAll() {
|
||||
app.forwarding.setEnabled(true, for: enableableRules.map(\.id))
|
||||
}
|
||||
|
||||
private func disableAll() {
|
||||
app.forwarding.setEnabled(false, for: runningRules.map(\.id))
|
||||
do {
|
||||
try app.forwardings.save(copy)
|
||||
open(copy)
|
||||
} catch { state.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private struct HostForwardingRow: View {
|
||||
let host: Host
|
||||
let rule: PortForwardingRule
|
||||
let edit: () -> Void
|
||||
let duplicate: () -> Void
|
||||
struct PortForwardingPagePresentation: ManagedCardPresentationModifier {
|
||||
@Bindable var state: PortForwardingPageState
|
||||
let ids: [UUID]
|
||||
let prune: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
|
||||
private var state: PortForwardingEditorPolicy {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(rule),
|
||||
savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(host.id),
|
||||
isEnabled: app.forwarding.isEnabled(rule.id),
|
||||
isRuntimeActive: runtime.isActive)
|
||||
}
|
||||
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: rule.id) }
|
||||
private var title: String { rule.label.isEmpty ? rule.kind.displayName : rule.label }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
Image(systemName: rule.kind.symbol)
|
||||
.foregroundStyle(runtime.isActive ? Color.green : Color.secondary)
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).lineLimit(1)
|
||||
Text(rule.summary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear(perform: prune).onChange(of: ids) { prune() }
|
||||
.confirmationDialog(
|
||||
"Delete Forwarding Rules?",
|
||||
isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } })
|
||||
) {
|
||||
Button("Delete", role: .destructive, action: delete)
|
||||
}
|
||||
Spacer(minLength: Metrics.Space.m)
|
||||
statusChip
|
||||
Toggle("", isOn: enabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
.disabled(!state.canToggle)
|
||||
.accessibilityLabel(Text("Enable \(title)"))
|
||||
Button(action: edit) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.alert(
|
||||
"Operation Failed",
|
||||
isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } })
|
||||
) {
|
||||
Button("OK") { state.error = nil }
|
||||
} message: {
|
||||
Text(state.error ?? "")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Edit \(title)"))
|
||||
}
|
||||
.contentShape(.rect)
|
||||
.onTapGesture(perform: edit)
|
||||
.contextMenu {
|
||||
HostForwardingContextMenu(
|
||||
host: host, rule: rule,
|
||||
edit: edit, duplicate: duplicate, delete: delete
|
||||
)
|
||||
}
|
||||
.help(Text(rule.summary))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusChip: some View {
|
||||
if state.isWaitingForSave {
|
||||
CardChip(text: String(localized: "Waiting for Save"), symbol: "clock")
|
||||
} else {
|
||||
CardChip(
|
||||
text: runtime.label,
|
||||
symbol: runtime.isActive ? "circle.fill" : "circle",
|
||||
tint: runtime.isActive ? Color.green : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var enabled: Binding<Bool> {
|
||||
Binding(
|
||||
get: { state.isEnabled || state.isRuntimeActive },
|
||||
set: { value in
|
||||
app.forwarding.setEnabled(value, for: rule.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HostForwardingContextMenu: View {
|
||||
let host: Host
|
||||
let rule: PortForwardingRule
|
||||
let edit: () -> Void
|
||||
let duplicate: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
struct PortForwardingDetailView: View {
|
||||
let ruleID: UUID?
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
@State private var draft = PortForwardingRule(hostID: UUID())
|
||||
@State private var loaded = false
|
||||
@State private var error: String?
|
||||
|
||||
private var state: PortForwardingEditorPolicy {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(rule),
|
||||
savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(host.id),
|
||||
isEnabled: app.forwarding.isEnabled(rule.id),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
|
||||
}
|
||||
private var isActive: Bool { !state.canEdit }
|
||||
private var command: String? { app.forwardings.sshCommand(for: rule, host: host) }
|
||||
|
||||
var body: some View {
|
||||
let isRunning = state.isEnabled || state.isRuntimeActive
|
||||
Button(
|
||||
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
|
||||
systemImage: isRunning ? MenuSymbol.stop : MenuSymbol.start,
|
||||
action: toggle
|
||||
)
|
||||
.disabled(!state.canToggle)
|
||||
Button("Edit", systemImage: MenuSymbol.edit, action: edit)
|
||||
Button("Duplicate", systemImage: MenuSymbol.duplicate, action: duplicate)
|
||||
.disabled(isActive)
|
||||
// Copy SSH Command repeats the verb above it, so only the first carries
|
||||
// the glyph.
|
||||
Button("Copy Address", systemImage: MenuSymbol.copy, action: copyAddress)
|
||||
.disabled(isActive)
|
||||
Button("Copy SSH Command", action: copyCommand)
|
||||
.disabled(isActive || command == nil)
|
||||
Divider()
|
||||
DestructiveMenuButton("Delete", action: delete).disabled(isActive)
|
||||
}
|
||||
|
||||
private func toggle() {
|
||||
if state.isEnabled || state.isRuntimeActive {
|
||||
app.forwarding.setEnabled(false, for: rule.id)
|
||||
} else {
|
||||
app.forwarding.setEnabled(true, for: rule.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyAddress() { copyToPasteboard(rule.bindAddress) }
|
||||
private func copyCommand() { if let command { copyToPasteboard(command) } }
|
||||
}
|
||||
|
||||
struct PortForwardingEditorSheet: View {
|
||||
let host: Host
|
||||
let isNew: Bool
|
||||
let back: () -> Void
|
||||
let delete: () -> Void
|
||||
let save: (PortForwardingRule) -> Void
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@State private var draft: PortForwardingRule
|
||||
|
||||
init(
|
||||
forwarding: PortForwardingRule, host: Host, isNew: Bool,
|
||||
back: @escaping () -> Void,
|
||||
delete: @escaping () -> Void,
|
||||
save: @escaping (PortForwardingRule) -> Void
|
||||
) {
|
||||
self.host = host
|
||||
self.isNew = isNew
|
||||
self.back = back
|
||||
self.delete = delete
|
||||
self.save = save
|
||||
_draft = State(initialValue: forwarding)
|
||||
private var saved: PortForwardingRule? { ruleID.flatMap(app.forwardings.portForwarding) }
|
||||
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: draft.id) }
|
||||
private var normalized: PortForwardingRule? { PortForwardingEditorPolicy.normalized(draft) }
|
||||
private var canSave: Bool {
|
||||
!runtime.isActive && normalized != nil && app.hostLibrary.host(draft.hostID) != nil
|
||||
&& (ruleID == nil || saved != nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VaultDetailScaffold(
|
||||
title: isNew ? "New Forwarding" : "Edit Forwarding",
|
||||
symbol: "arrow.left.arrow.right", close: cancel,
|
||||
closeLabel: "Back", closeSymbol: "chevron.backward"
|
||||
title: ruleID == nil
|
||||
? "New Forwarding" : "\(draft.displayName(hostName: app.hostLibrary.host(draft.hostID)?.name))",
|
||||
symbol: "arrow.left.arrow.right", close: window.closeVaultWork
|
||||
) {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Enabled", isOn: enabled)
|
||||
.disabled(!state.canToggle)
|
||||
LabeledContent(
|
||||
"Status",
|
||||
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : runtime.label
|
||||
)
|
||||
if state.isWaitingForSave {
|
||||
Text("This forwarding will start after the host is saved.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let context = app.forwarding.connection(for: draft.id), context.journey.isPresented {
|
||||
journey(context)
|
||||
} else {
|
||||
Form {
|
||||
if ruleID != nil {
|
||||
Section {
|
||||
LabeledContent("Status", value: runtime.label)
|
||||
if runtime.isActive {
|
||||
Button("Stop") { app.forwarding.stop(draft.id) }
|
||||
} else {
|
||||
Button(runtime.isFailed ? "Retry" : "Start") { app.forwarding.start(draft.id) }
|
||||
.disabled(saved == nil || normalized != saved || !canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PortForwardingFields(rule: $draft)
|
||||
.disabled(!state.canEdit)
|
||||
PortForwardingHostPicker(rule: $draft, hosts: app.forwardings.hosts)
|
||||
.disabled(runtime.isActive)
|
||||
PortForwardingFields(rule: $draft).disabled(runtime.isActive)
|
||||
if let error { Text(error).foregroundStyle(.red) }
|
||||
if ruleID != nil && saved == nil {
|
||||
Text("The forwarding rule is unavailable.").foregroundStyle(.secondary)
|
||||
}
|
||||
}.formStyle(.grouped)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
} footer: {
|
||||
HStack {
|
||||
if !isNew {
|
||||
Button("Delete", role: .destructive) {
|
||||
guard state.canEdit else { return }
|
||||
delete()
|
||||
back()
|
||||
}
|
||||
.disabled(!state.canEdit)
|
||||
}
|
||||
if runtime.isActive { Button("Stop") { app.forwarding.stop(draft.id) } }
|
||||
Spacer()
|
||||
Button("Cancel", action: cancel).keyboardShortcut(.cancelAction)
|
||||
Button("Save") { commit() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(normalizedDraft == nil || !state.canEdit)
|
||||
}
|
||||
.padding(Metrics.Space.l)
|
||||
Button("Close", action: window.closeVaultWork)
|
||||
Button("Save", action: save).buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction).disabled(!canSave)
|
||||
}.padding(Metrics.Space.l)
|
||||
}
|
||||
.onAppear {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
if let saved { draft = saved }
|
||||
}
|
||||
}
|
||||
|
||||
private var normalizedDraft: PortForwardingRule? {
|
||||
PortForwardingEditorPolicy.normalized(draft)
|
||||
private func journey(_ context: PortForwardingConnection) -> some View {
|
||||
let promptID = context.authenticationPrompt?.id
|
||||
let hostKeyRequestID = context.hostKeyRequestID
|
||||
return ConnectionJourneyView(
|
||||
journey: context.journey, authenticationPrompt: context.authenticationPrompt,
|
||||
hostKey: context.connection.pendingHostKey,
|
||||
submitAuthentication: { identity, username, password, passphrase, key, save in
|
||||
guard let promptID else { return }
|
||||
app.forwarding.submitAuthentication(
|
||||
for: draft.id, promptID: promptID, identityID: identity,
|
||||
username: username, password: password, passphrase: passphrase, keyID: key, save: save)
|
||||
},
|
||||
continueHostKey: {
|
||||
guard let hostKeyRequestID else { return }
|
||||
app.forwarding.acceptHostKey(
|
||||
for: draft.id, requestID: hostKeyRequestID, observation: $0, remember: false)
|
||||
},
|
||||
trustHostKey: {
|
||||
guard let hostKeyRequestID else { return }
|
||||
app.forwarding.acceptHostKey(
|
||||
for: draft.id, requestID: hostKeyRequestID, observation: $0, remember: true)
|
||||
},
|
||||
denyHostKey: cancel, retry: { app.forwarding.retry(draft.id) }, cancel: cancel,
|
||||
keyboardAuthentication: context.connection.keyboardAuthentication, cancelKeyboardAuthentication: cancel)
|
||||
}
|
||||
|
||||
private var runtime: PortForwardingRuntimeState { app.forwarding.state(for: draft.id) }
|
||||
|
||||
private var state: PortForwardingEditorPolicy {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: normalizedDraft,
|
||||
savedRule: app.forwardings.portForwarding(draft.id),
|
||||
savedHost: app.hostLibrary.host(host.id),
|
||||
isEnabled: app.forwarding.isEnabled(draft.id),
|
||||
isRuntimeActive: runtime.isActive)
|
||||
}
|
||||
|
||||
private var enabled: Binding<Bool> {
|
||||
Binding(
|
||||
get: { state.isEnabled || state.isRuntimeActive },
|
||||
set: { value in
|
||||
app.forwarding.setEnabled(value, for: draft.id)
|
||||
})
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
if isNew { app.forwarding.discardPending([draft.id]) }
|
||||
back()
|
||||
app.forwarding.stop(draft.id)
|
||||
window.closeVaultWork()
|
||||
}
|
||||
|
||||
private func commit() {
|
||||
guard state.canEdit, let value = normalizedDraft else { return }
|
||||
save(value)
|
||||
back()
|
||||
private func save() {
|
||||
guard canSave, let normalized else { return }
|
||||
do {
|
||||
try app.forwardings.save(normalized)
|
||||
window.selectVaultItem(.forwarding(normalized.id), on: .portForwarding)
|
||||
window.openVaultWork(.forwardingEditor(normalized.id), on: .portForwarding)
|
||||
draft = normalized
|
||||
error = nil
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct VaultView: View {
|
||||
case .welcome: WelcomeView()
|
||||
case .hosts: HostsPageView(state: pages.hosts)
|
||||
case .keychain: KeychainPageView(state: pages.keychain)
|
||||
case .portForwarding: PortForwardingPageView(state: pages.portForwarding)
|
||||
case .snippets: SnippetsPageView(state: pages.snippets)
|
||||
case .knownHosts: KnownHostsPageView(state: pages.knownHosts)
|
||||
}
|
||||
@@ -47,6 +48,8 @@ struct VaultWorkDetailView: View {
|
||||
},
|
||||
close: window.closeVaultWork
|
||||
)
|
||||
case .forwardingEditor(let id):
|
||||
PortForwardingDetailView(ruleID: id)
|
||||
case .identityEditor(let id):
|
||||
NewIdentitySheet(
|
||||
identity: id.flatMap(app.hostLibrary.identity),
|
||||
|
||||
@@ -187,6 +187,7 @@ final class WindowModel {
|
||||
Set(store.hosts.map { .host($0.id) })
|
||||
.union(store.keys.map { .key($0.id) })
|
||||
.union(store.identities.map { .identity($0.id) })
|
||||
.union(store.portForwardings.map { .forwarding($0.id) })
|
||||
.union(store.snippets.map { .snippet($0.id) })
|
||||
.union(store.knownHosts.map { .knownHost($0.id) })
|
||||
}
|
||||
@@ -414,6 +415,7 @@ final class WindowModel {
|
||||
case .welcome: return nil
|
||||
case .hosts: return .hosts
|
||||
case .keychain: return .keychain
|
||||
case .portForwarding: return .portForwarding
|
||||
case .snippets: return .snippets
|
||||
case .knownHosts: return .knownHosts
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ struct WorkspaceNavigatorView: View {
|
||||
case .welcome: 0
|
||||
case .hosts: app.hostLibrary.hosts.count
|
||||
case .keychain: app.hostLibrary.keys.count + app.hostLibrary.identities.count
|
||||
case .portForwarding: app.forwardings.rules.count
|
||||
case .snippets: app.hostLibrary.snippets.count
|
||||
case .knownHosts: app.knownHosts.entries.count
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct PortForwardingJourneyTests {
|
||||
@Test func windowsShareForwardingAuthenticationAndDeletingItsJumpHostCancelsIt() async throws {
|
||||
let jump = Host(name: "Jump", host: "jump.invalid")
|
||||
let host = Host(name: "Server", host: "server.invalid", jumpHostID: jump.id)
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let library = HostLibraryModel(hosts: [jump, host], portForwardings: [rule])
|
||||
let app = AppModel(hosts: library)
|
||||
defer { app.shutdownForTermination() }
|
||||
let first = WindowModel()
|
||||
let second = WindowModel()
|
||||
first.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
|
||||
second.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding)
|
||||
app.forwarding.start(rule.id)
|
||||
try await wait { app.forwarding.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
let context = try #require(app.forwarding.connection(for: rule.id))
|
||||
first.closeVaultWork()
|
||||
first.openPage(.snippets)
|
||||
#expect(second.vaultWorkRoute == .forwardingEditor(rule.id))
|
||||
#expect(context.authenticationPrompt != nil)
|
||||
#expect(app.requestHostDeletion(jump.id))
|
||||
#expect(context.authenticationPrompt == nil)
|
||||
#expect(app.forwarding.state(for: rule.id) == .stopped)
|
||||
#expect(app.forwarding.connection(for: rule.id) == nil)
|
||||
}
|
||||
|
||||
@Test func savedRulesNeverStartUntilRequestedAndFailureNeverRetries() async throws {
|
||||
let fixture = Fixture(mode: .failure)
|
||||
let rule = fixture.rules[0]
|
||||
#expect(fixture.sessions.isEmpty)
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait {
|
||||
if case .failed = fixture.controller.state(for: rule.id) { return true }
|
||||
return false
|
||||
}
|
||||
#expect(fixture.sessions.count == 1)
|
||||
fixture.controller.reconcileConfiguration()
|
||||
#expect(fixture.sessions.count == 1)
|
||||
#expect(fixture.controller.connection(for: rule.id)?.journey.purpose == .portForwarding)
|
||||
fixture.controller.retry(rule.id)
|
||||
try await wait { fixture.sessions.count == 2 }
|
||||
fixture.controller.stopAll()
|
||||
}
|
||||
|
||||
@Test func sharedHostAuthenticatesOnceAndStoppingOneRulePreservesItsPeer() async throws {
|
||||
let fixture = Fixture(mode: .keyboard)
|
||||
defer { fixture.controller.stopAll() }
|
||||
let first = fixture.rules[0]
|
||||
let second = fixture.rules[1]
|
||||
fixture.controller.start(first.id)
|
||||
fixture.controller.start(second.id)
|
||||
try await wait {
|
||||
fixture.controller.connection(for: first.id)?.connection.keyboardAuthentication.current != nil
|
||||
}
|
||||
let context = try #require(fixture.controller.connection(for: first.id))
|
||||
#expect(context === fixture.controller.connection(for: second.id))
|
||||
#expect(fixture.sessions.count == 1)
|
||||
fixture.controller.stop(first.id)
|
||||
#expect(context.connection.keyboardAuthentication.current != nil)
|
||||
try await finishAuthentication(context)
|
||||
try await wait { fixture.controller.state(for: second.id) == .running(activeConnections: 0) }
|
||||
let session = try #require(fixture.sessions.first)
|
||||
#expect(await session.starts == [second.id])
|
||||
#expect(fixture.controller.state(for: first.id) == .stopped)
|
||||
#expect(!context.journey.plainTextLog.contains("secret-response"))
|
||||
#expect(!context.journey.plainTextLog.contains("private instruction"))
|
||||
}
|
||||
|
||||
@Test func stoppingTheLastRuleCancelsAuthenticationAndRejectsLateRequestsAndCallbacks() async throws {
|
||||
let fixture = Fixture(mode: .keyboard)
|
||||
let rule = fixture.rules[0]
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait { fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil }
|
||||
let old = try #require(fixture.controller.connection(for: rule.id))
|
||||
let challenge = try #require(old.connection.keyboardAuthentication.current)
|
||||
let session = try #require(fixture.sessions.first)
|
||||
let request = try #require(await session.requests.first)
|
||||
fixture.controller.stop(rule.id)
|
||||
#expect(old.connection.keyboardAuthentication.pending.isEmpty)
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait {
|
||||
fixture.sessions.count == 2
|
||||
&& fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil
|
||||
}
|
||||
old.connection.keyboardAuthentication.respond(challenge.id, answers: ["stale"])
|
||||
await session.fail()
|
||||
if let responder = request.keyboardInteractive {
|
||||
await #expect(throws: CancellationError.self) { try await responder(challenge) }
|
||||
}
|
||||
#expect(fixture.controller.state(for: rule.id) == .awaitingAuthentication)
|
||||
#expect(old.connection.keyboardAuthentication.pending.isEmpty)
|
||||
fixture.controller.stopAll()
|
||||
}
|
||||
|
||||
@Test func hostTrustContinuesTheOriginalConnectionAndCanBeTemporary() async throws {
|
||||
let fixture = Fixture(mode: .trust)
|
||||
defer { fixture.controller.stopAll() }
|
||||
let rule = fixture.rules[0]
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait { fixture.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
|
||||
let context = try #require(fixture.controller.connection(for: rule.id))
|
||||
let prompt = try #require(context.connection.pendingHostKey)
|
||||
fixture.controller.acceptHostKey(
|
||||
for: rule.id, requestID: try #require(context.hostKeyRequestID),
|
||||
observation: prompt.observation, remember: false)
|
||||
try await wait { fixture.controller.state(for: rule.id) == .running(activeConnections: 0) }
|
||||
#expect(fixture.sessions.count == 1)
|
||||
#expect(await fixture.sessions[0].requests.count == 1)
|
||||
#expect(fixture.library.knownHosts.isEmpty)
|
||||
}
|
||||
|
||||
@Test func retiredTrustAndCredentialFormsCannotAuthorizeAReplacementConnection() async throws {
|
||||
let trust = Fixture(mode: .trust)
|
||||
defer { trust.controller.stopAll() }
|
||||
let rule = trust.rules[0]
|
||||
trust.controller.start(rule.id)
|
||||
try await wait { trust.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
|
||||
let old = try #require(trust.controller.connection(for: rule.id))
|
||||
let oldID = try #require(old.hostKeyRequestID)
|
||||
let observation = try #require(old.connection.pendingHostKey?.observation)
|
||||
trust.controller.stop(rule.id)
|
||||
trust.controller.start(rule.id)
|
||||
try await wait { trust.controller.connection(for: rule.id)?.connection.pendingHostKey != nil }
|
||||
let current = try #require(trust.controller.connection(for: rule.id))
|
||||
trust.controller.acceptHostKey(for: rule.id, requestID: oldID, observation: observation, remember: true)
|
||||
#expect(current.connection.pendingHostKey != nil)
|
||||
#expect(trust.library.knownHosts.isEmpty)
|
||||
trust.controller.acceptHostKey(
|
||||
for: rule.id, requestID: try #require(current.hostKeyRequestID), observation: observation, remember: true)
|
||||
try await wait { trust.controller.state(for: rule.id) == .running(activeConnections: 0) }
|
||||
#expect(trust.library.knownHosts.count == 1)
|
||||
|
||||
let credentials = Fixture(mode: .immediate, hasIdentity: false)
|
||||
defer { credentials.controller.stopAll() }
|
||||
let credentialRule = credentials.rules[0]
|
||||
credentials.controller.start(credentialRule.id)
|
||||
try await wait { credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt != nil }
|
||||
let oldPrompt = try #require(credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt)
|
||||
credentials.controller.stop(credentialRule.id)
|
||||
credentials.controller.start(credentialRule.id)
|
||||
try await wait { credentials.controller.connection(for: credentialRule.id)?.authenticationPrompt != nil }
|
||||
credentials.controller.submitAuthentication(
|
||||
for: credentialRule.id, promptID: oldPrompt.id, identityID: nil, username: "stale",
|
||||
password: "stale", passphrase: nil, keyID: nil, save: true)
|
||||
#expect(credentials.library.identities.isEmpty)
|
||||
#expect(credentials.controller.state(for: credentialRule.id) == .awaitingAuthentication)
|
||||
}
|
||||
|
||||
@Test func encryptedKeyPassphraseResumesPreparationInTheJourney() async throws {
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "forwarding", passphrase: "test-passphrase")
|
||||
let key = SSHKeyRef(
|
||||
label: "Encrypted", algorithm: .ed25519, publicKey: material.publicKey,
|
||||
fingerprint: material.fingerprint, credentialRef: "test-key", storage: .thisDevice, hasPassphrase: true)
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Server", host: "server.invalid", identityID: identity.id)
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let library = HostLibraryModel(hosts: [host], identities: [identity], portForwardings: [rule])
|
||||
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey)))
|
||||
let controller = PortForwardingController(hosts: library) {
|
||||
ForwardingTestSession(mode: .immediate, count: $0, failure: $1)
|
||||
}
|
||||
defer { controller.stopAll() }
|
||||
controller.start(rule.id)
|
||||
try await wait { controller.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
let context = try #require(controller.connection(for: rule.id))
|
||||
let prompt = try #require(context.authenticationPrompt)
|
||||
#expect(prompt.requiresPassphrase)
|
||||
controller.submitAuthentication(
|
||||
for: rule.id, promptID: prompt.id, identityID: identity.id, username: "deploy",
|
||||
password: nil, passphrase: "test-passphrase", keyID: key.id, save: false)
|
||||
try await wait { controller.state(for: rule.id) == .running(activeConnections: 0) }
|
||||
#expect(!context.journey.plainTextLog.contains("test-passphrase"))
|
||||
}
|
||||
|
||||
@Test func savedCredentialsContinueTheExplicitOpeningWithoutStartingOtherRules() async throws {
|
||||
let fixture = Fixture(mode: .immediate, hasIdentity: false)
|
||||
defer { fixture.controller.stopAll() }
|
||||
let rule = fixture.rules[0]
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait { fixture.controller.connection(for: rule.id)?.authenticationPrompt != nil }
|
||||
let context = try #require(fixture.controller.connection(for: rule.id))
|
||||
let prompt = try #require(context.authenticationPrompt)
|
||||
#expect(context.journey.waitsForLocalIdentity)
|
||||
fixture.controller.submitAuthentication(
|
||||
for: rule.id, promptID: prompt.id, identityID: nil,
|
||||
username: "deploy", password: "test-password", passphrase: nil, keyID: nil, save: true)
|
||||
try await wait { fixture.controller.state(for: rule.id) == .running(activeConnections: 0) }
|
||||
#expect(
|
||||
fixture.library.identity(for: try #require(fixture.library.host(fixture.host.id)))?.username == "deploy")
|
||||
#expect(fixture.controller.state(for: fixture.rules[1].id) == .stopped)
|
||||
#expect(!context.journey.plainTextLog.contains("test-password"))
|
||||
}
|
||||
|
||||
@Test func jumpChallengesStayInTheForwardingJourneyAndConfigurationChangesStopIt() async throws {
|
||||
let fixture = Fixture(mode: .keyboard, usesJump: true)
|
||||
let rule = fixture.rules[0]
|
||||
fixture.controller.start(rule.id)
|
||||
try await wait { fixture.controller.connection(for: rule.id)?.connection.keyboardAuthentication.current != nil }
|
||||
let context = try #require(fixture.controller.connection(for: rule.id))
|
||||
let challenge = try #require(context.connection.keyboardAuthentication.current)
|
||||
#expect(challenge.hostID == fixture.host.jumpHostID)
|
||||
#expect(context.journey.checkpoint?.endpointIndex == 1)
|
||||
var jump = try #require(fixture.library.host(challenge.hostID))
|
||||
jump.port = 2222
|
||||
fixture.library.upsert(jump)
|
||||
#expect(fixture.controller.state(for: rule.id) == .stopped)
|
||||
#expect(context.connection.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(fixture.sessions.count == 1)
|
||||
}
|
||||
|
||||
@Test func listenerFailureDoesNotDisconnectAnExistingRuleAndDisconnectNeverRestarts() async throws {
|
||||
let fixture = Fixture(mode: .listenerFailure)
|
||||
defer { fixture.controller.stopAll() }
|
||||
let first = fixture.rules[0]
|
||||
let second = fixture.rules[1]
|
||||
fixture.controller.start(first.id)
|
||||
try await wait { fixture.controller.state(for: first.id) == .running(activeConnections: 0) }
|
||||
fixture.controller.start(second.id)
|
||||
try await wait {
|
||||
if case .failed = fixture.controller.state(for: second.id) { return true }
|
||||
return false
|
||||
}
|
||||
#expect(fixture.controller.state(for: first.id).isActive)
|
||||
#expect(fixture.sessions.count == 1)
|
||||
let session = try #require(fixture.sessions.first)
|
||||
#expect(await session.disconnects == 0)
|
||||
await session.fail()
|
||||
try await wait {
|
||||
if case .failed = fixture.controller.state(for: first.id) { return true }
|
||||
return false
|
||||
}
|
||||
fixture.controller.reconcileConfiguration()
|
||||
#expect(fixture.sessions.count == 1)
|
||||
}
|
||||
|
||||
@Test func rapidStopAndStartCannotLeaveTheNewListenerStopped() async throws {
|
||||
let fixture = Fixture(mode: .immediate)
|
||||
defer { fixture.controller.stopAll() }
|
||||
let first = fixture.rules[0]
|
||||
let second = fixture.rules[1]
|
||||
fixture.controller.start(first.id)
|
||||
fixture.controller.start(second.id)
|
||||
try await wait {
|
||||
fixture.rules.allSatisfy { fixture.controller.state(for: $0.id) == .running(activeConnections: 0) }
|
||||
}
|
||||
fixture.controller.stop(first.id)
|
||||
fixture.controller.start(first.id)
|
||||
try await wait { fixture.controller.state(for: first.id) == .running(activeConnections: 0) }
|
||||
#expect(await fixture.sessions[0].listening == Set(fixture.rules.map(\.id)))
|
||||
#expect(fixture.sessions.count == 1)
|
||||
}
|
||||
|
||||
private func finishAuthentication(_ context: PortForwardingConnection) async throws {
|
||||
let first = try #require(context.connection.keyboardAuthentication.current)
|
||||
context.connection.keyboardAuthentication.respond(first.id, answers: ["secret-response"])
|
||||
try await wait { context.connection.keyboardAuthentication.current?.round == 2 }
|
||||
let second = try #require(context.connection.keyboardAuthentication.current)
|
||||
#expect(first.connectionID == second.connectionID && first.id != second.id)
|
||||
context.connection.keyboardAuthentication.respond(first.id, answers: ["stale"])
|
||||
#expect(context.connection.keyboardAuthentication.current?.id == second.id)
|
||||
context.connection.keyboardAuthentication.respond(second.id, answers: [""])
|
||||
}
|
||||
|
||||
private func wait(_ predicate: () -> Bool) async throws {
|
||||
for _ in 0..<2000 {
|
||||
if predicate() { return }
|
||||
try await Task.sleep(for: .milliseconds(1))
|
||||
}
|
||||
throw CocoaError(.coderInvalidValue)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class Fixture {
|
||||
let host: Host
|
||||
let rules: [PortForwardingRule]
|
||||
let library: HostLibraryModel
|
||||
var sessions: [ForwardingTestSession] = []
|
||||
let mode: ForwardingTestSession.Mode
|
||||
lazy var controller = PortForwardingController(hosts: library) { [unowned self] count, failure in
|
||||
let session = ForwardingTestSession(mode: mode, count: count, failure: failure)
|
||||
sessions.append(session)
|
||||
return session
|
||||
}
|
||||
init(mode: ForwardingTestSession.Mode, hasIdentity: Bool = true, usesJump: Bool = false) {
|
||||
self.mode = mode
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let jump = Host(name: "Jump", host: "jump.invalid", identityID: identity.id)
|
||||
host = Host(
|
||||
name: "Server", host: "server.invalid", identityID: hasIdentity ? identity.id : nil,
|
||||
jumpHostID: usesJump ? jump.id : nil)
|
||||
rules = [
|
||||
PortForwardingRule(hostID: host.id, bindPort: 8000),
|
||||
PortForwardingRule(hostID: host.id, bindPort: 9000),
|
||||
]
|
||||
library = HostLibraryModel(
|
||||
hosts: usesJump ? [jump, host] : [host], identities: hasIdentity ? [identity] : [],
|
||||
portForwardings: rules)
|
||||
library.willDeletePortForwardings = { [weak self] in self?.controller.stop($0) }
|
||||
library.didChangeHostConnection = { [weak self] in self?.controller.hostConfigurationChanged($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor ForwardingTestSession: SSHForwardingSessionProtocol {
|
||||
enum Mode: Sendable { case immediate, keyboard, trust, failure, listenerFailure }
|
||||
let mode: Mode
|
||||
let count: SSHForwardingSession.ConnectionHandler
|
||||
let failure: SSHForwardingSession.FailureHandler
|
||||
var requests: [SSHConnectionRequest] = []
|
||||
var starts: [UUID] = []
|
||||
var listening: Set<UUID> = []
|
||||
var disconnects = 0
|
||||
init(
|
||||
mode: Mode, count: @escaping SSHForwardingSession.ConnectionHandler,
|
||||
failure: @escaping SSHForwardingSession.FailureHandler
|
||||
) {
|
||||
self.mode = mode
|
||||
self.count = count
|
||||
self.failure = failure
|
||||
}
|
||||
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws {
|
||||
requests.append(request)
|
||||
let configuration = request.configuration
|
||||
if mode == .failure { throw TermindSSHError.disconnected }
|
||||
if mode == .trust {
|
||||
let observation = SSHHostKeyObservation(
|
||||
hostname: configuration.host, port: configuration.port,
|
||||
algorithm: .ed25519, fingerprint: "SHA256:test")
|
||||
let decision = try await request.authorizeHostKey?(
|
||||
SSHHostKeyChallenge(hostID: configuration.hostID, observation: observation, expectedHostKeys: []))
|
||||
guard decision == .accept else { throw CancellationError() }
|
||||
}
|
||||
if mode == .keyboard {
|
||||
let connectionID = UUID()
|
||||
for round in 1...2 {
|
||||
let challenge = SSHKeyboardInteractiveChallenge(
|
||||
connectionID: connectionID,
|
||||
hostID: configuration.jumps.first?.id ?? configuration.hostID,
|
||||
hostname: configuration.host, port: 22, username: configuration.username, round: round,
|
||||
name: "PAM", instruction: "private instruction", prompts: [.init(text: "Code", echo: false)])
|
||||
_ = try await request.keyboardInteractive?(challenge)
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
progress?(.connected(configuration.hostID))
|
||||
}
|
||||
func start(_ rule: PortForwardingRule) async throws {
|
||||
if mode == .listenerFailure && rule.bindPort == 9000 { throw TermindSSHError.route("Port in use") }
|
||||
starts.append(rule.id)
|
||||
listening.insert(rule.id)
|
||||
}
|
||||
func stop(_ id: UUID) async { listening.remove(id) }
|
||||
func disconnect() async {
|
||||
disconnects += 1
|
||||
listening.removeAll()
|
||||
}
|
||||
func fail() { failure("Connection lost") }
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user