Parse ssh command
This commit is contained in:
1 parent
8ed3a5159c
commit
509484ae4c
16 files changed
+1426
-177
No files matched your search
@@ -9525,6 +9525,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter a value for %@." : {
|
||||
"comment" : "A description of an error that might occur when parsing an SSH argument. The argument is a flag that needs a value.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请输入%@的值。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請輸入%@的值。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter an absolute device path and a valid baud rate." : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -9557,6 +9575,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter an SSH destination." : {
|
||||
"comment" : "Error message when the user has not entered an SSH destination.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请输入SSH目标地址。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請輸入SSH目標位址。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter Sends" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -10551,6 +10587,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Finish the bracketed IPv6 address." : {
|
||||
"comment" : "Error message when an IPv6 address is not properly bracketed.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请补全方括号中的IPv6地址。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請補全方括號中的IPv6位址。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Finish the quoted or escaped argument." : {
|
||||
"comment" : "Error message when an argument is not properly closed.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请补全引号或转义参数。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請補全引號或跳脫參數。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Flow Control" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -12837,8 +12909,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Invalid SSH destination." : {
|
||||
"comment" : "Error message displayed when an invalid SSH destination is entered.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH目标地址无效。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH目標位址無效。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Invalid SSH port." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -14486,6 +14575,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"More than one saved host is named “%@”." : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "有多个已保存的主机名为“%@”。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "有多個已儲存的主機名為「%@」。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mouse" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -19478,6 +19583,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Remote commands are not supported here." : {
|
||||
"comment" : "Error message displayed when a remote command is entered in a local context.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "此处不支持远程命令。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "此處不支援遠端命令。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Remote Forward" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -20753,7 +20876,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Save a host here, or enter ssh user@host on the Welcome page to connect once." : {
|
||||
"comment" : "A description of the empty state of the hosts page.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "在此保存主机,或在“欢迎”页面输入ssh user@host进行一次性连接。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "在此儲存主機,或在「歡迎」頁面輸入ssh user@host進行一次性連線。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Save a host here, or enter user@host on the Welcome page to connect once." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -20851,6 +20993,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Saved" : {
|
||||
"comment" : "A label displayed next to a saved target.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已保存"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已儲存"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"saved conversations" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -21002,7 +21162,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search hosts or templates, or type ssh user@host" : {
|
||||
"comment" : "Prompt for a search field in the welcome view.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "搜索主机或模板,或输入ssh user@host"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "搜尋主機或範本,或輸入ssh user@host"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search hosts or templates, or type user@host" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -21801,6 +21980,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Shell expressions are not supported here." : {
|
||||
"comment" : "Error message when shell expressions are not supported in the current context.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "此处不支持Shell表达式。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "此處不支援Shell運算式。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Shell Integration" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -22890,6 +23087,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH options: -p port, -l user, -J jump; -o HostName, User, Port, ProxyJump." : {
|
||||
"comment" : "A help message for the SSH field.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH参数:-p端口、-l用户名、-J跳板机;-o支持HostName、User、Port和ProxyJump。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH參數:-p連接埠、-l使用者名稱、-J跳板主機;-o支援HostName、User、Port和ProxyJump。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Standard error" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -27958,6 +28173,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unsupported SSH option: %@." : {
|
||||
"comment" : "A description of an error that might occur when parsing a Quick Connect string. The argument is the name of the unsupported option.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "不支持的SSH参数:%@。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "不支援的SSH參數:%@。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Up Arrow" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -28345,6 +28578,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use one -J option with comma-separated jump hosts." : {
|
||||
"comment" : "Error message when a comma-separated list of jump hosts is not supported.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "请使用一个-J选项,并用逗号分隔多个跳板机。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "請使用一個-J選項,並以逗號分隔多個跳板主機。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use the AI Assistant" : {
|
||||
"comment" : "A feature of Termind Pro.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
@@ -31,6 +31,17 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
||||
trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> [SSHRouteHopConfiguration]
|
||||
func prepareSSHConnection(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
|
||||
) throws -> SSHConnectionPreparation
|
||||
func sshJumpConfigurations(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> [SSHRouteHopConfiguration]
|
||||
func saveConnectionRoute(
|
||||
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?,
|
||||
authenticationHostID: Host.ID?, suppliedPassword: String?
|
||||
) throws -> SSHConnectionRoute
|
||||
func transportProxy(for host: Host) throws -> SSHTransportProxy?
|
||||
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
|
||||
func passwordCredential(for identity: Identity) throws -> String?
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// A session-owned route snapshot. Hosts and login hints contain no secrets and
|
||||
/// do not become Vault records until an explicit save.
|
||||
public nonisolated struct SSHConnectionRoute: Hashable, Sendable {
|
||||
public var hosts: [Host]
|
||||
public var usernames: [Host.ID: String]
|
||||
public var sourceHostIDs: [Host.ID: Host.ID]
|
||||
|
||||
public init(
|
||||
hosts: [Host], usernames: [Host.ID: String] = [:], sourceHostIDs: [Host.ID: Host.ID] = [:]
|
||||
) {
|
||||
self.hosts = hosts
|
||||
self.usernames = usernames
|
||||
self.sourceHostIDs = sourceHostIDs
|
||||
}
|
||||
|
||||
public var isValid: Bool {
|
||||
guard !hosts.isEmpty, hosts.count <= 8, Set(hosts.map(\.id)).count == hosts.count else { return false }
|
||||
return hosts.enumerated().allSatisfy { index, host in
|
||||
host.jumpHostID == (index == 0 ? nil : hosts[index - 1].id)
|
||||
}
|
||||
}
|
||||
|
||||
public func host(_ id: Host.ID) -> Host? { hosts.first { $0.id == id } }
|
||||
|
||||
/// Login hints fill only unbound Hosts. An Identity still resolves its own
|
||||
/// credentials, and an interactive submission always takes precedence.
|
||||
public func submissions(
|
||||
merging submissions: [Host.ID: SSHAuthenticationSubmission]
|
||||
) -> [Host.ID: SSHAuthenticationSubmission] {
|
||||
var result = submissions
|
||||
for host in hosts where host.identityID == nil && result[host.id] == nil {
|
||||
if let username = usernames[host.id] {
|
||||
result[host.id] = SSHAuthenticationSubmission(username: username)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static var invalidRouteError: TermindSSHError {
|
||||
.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
||||
}
|
||||
}
|
||||
@@ -386,8 +386,16 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
guard let route = validatedRoute(for: host) else {
|
||||
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
||||
}
|
||||
return try prepareSSHConnection(for: SSHConnectionRoute(hosts: route), submissions: submissions)
|
||||
}
|
||||
|
||||
public func prepareSSHConnection(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
|
||||
) throws -> SSHConnectionPreparation {
|
||||
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
||||
let submissions = route.submissions(merging: submissions)
|
||||
return SSHConnectionPreparation(
|
||||
nodes: route.map { node in
|
||||
nodes: route.hosts.map { node in
|
||||
let submission = submissions[node.id]
|
||||
let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node)
|
||||
let keyID = submission != nil ? submission?.keyID : identity?.keyID
|
||||
@@ -501,6 +509,32 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
guard let route = validatedRoute(for: host) else {
|
||||
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
|
||||
}
|
||||
return try sshJumpConfigurations(
|
||||
route: route, submissions: submissions, temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: trustPolicy, accessedIdentityIDs: &accessedIdentityIDs
|
||||
)
|
||||
}
|
||||
|
||||
public func sshJumpConfigurations(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
||||
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
||||
) throws -> [SSHRouteHopConfiguration] {
|
||||
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
||||
var accessedIdentityIDs: Set<Identity.ID> = []
|
||||
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
|
||||
return try sshJumpConfigurations(
|
||||
route: route.hosts, submissions: route.submissions(merging: submissions),
|
||||
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy,
|
||||
accessedIdentityIDs: &accessedIdentityIDs
|
||||
)
|
||||
}
|
||||
|
||||
private func sshJumpConfigurations(
|
||||
route: [Host], submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy,
|
||||
accessedIdentityIDs: inout Set<Identity.ID>
|
||||
) throws -> [SSHRouteHopConfiguration] {
|
||||
return try route.dropLast().enumerated().map { offset, jump in
|
||||
do {
|
||||
if let submission = submissions[jump.id] {
|
||||
@@ -1731,6 +1765,90 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Saves a complete temporary route in one Vault transaction. Existing Hosts
|
||||
/// and Identities are reused or copied, never edited by a one-off command.
|
||||
public func saveConnectionRoute(
|
||||
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission? = nil,
|
||||
authenticationHostID: Host.ID? = nil, suppliedPassword: String? = nil
|
||||
) throws -> SSHConnectionRoute {
|
||||
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
|
||||
var batch = SSHImportBatch()
|
||||
var saved: [Host] = []
|
||||
var usernames: [Host.ID: String] = [:]
|
||||
var passwords: [(Identity, String)] = []
|
||||
var usedNames = Set(hosts.map(\.name))
|
||||
for var node in route.hosts {
|
||||
let originalID = node.id
|
||||
let source = route.sourceHostIDs[originalID].flatMap(host) ?? host(originalID)
|
||||
let username = route.usernames[originalID] ?? identity(for: node)?.username
|
||||
if node.id == authenticationHostID, let authentication {
|
||||
if let selected = authentication.identityID.flatMap(identity), suppliedPassword == nil {
|
||||
node.identityID = selected.id
|
||||
} else {
|
||||
let login = Identity(
|
||||
name: authentication.username, username: authentication.username,
|
||||
credentialRef: suppliedPassword == nil ? "" : "termind.password.\(UUID().uuidString)",
|
||||
keyID: authentication.keyID
|
||||
)
|
||||
batch.identities.append(login)
|
||||
if let suppliedPassword { passwords.append((login, suppliedPassword)) }
|
||||
node.identityID = login.id
|
||||
}
|
||||
} else if node.identityID == nil, let username, !username.isEmpty {
|
||||
let matches = (identities + batch.identities).filter { $0.username == username }
|
||||
if matches.count == 1 {
|
||||
node.identityID = matches[0].id
|
||||
} else {
|
||||
let login = Identity(name: username, username: username)
|
||||
batch.identities.append(login)
|
||||
node.identityID = login.id
|
||||
}
|
||||
}
|
||||
node.jumpHostID = saved.last?.id
|
||||
if let existing = host(originalID), existing != node { node.id = UUID() }
|
||||
if host(node.id) == nil {
|
||||
let base = source.map { String(localized: "\($0.name) copy") } ?? node.name
|
||||
var name = base
|
||||
var suffix = 2
|
||||
while usedNames.contains(name) {
|
||||
name = "\(base) (\(suffix))"
|
||||
suffix += 1
|
||||
}
|
||||
node.name = name
|
||||
usedNames.insert(name)
|
||||
batch.hosts.append(node)
|
||||
}
|
||||
usernames[node.id] = originalID == authenticationHostID ? authentication?.username : username
|
||||
saved.append(node)
|
||||
}
|
||||
var written: [Identity] = []
|
||||
do {
|
||||
for (identity, password) in passwords {
|
||||
try keychain.setString(password, for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
written.append(identity)
|
||||
}
|
||||
if !batch.hosts.isEmpty || !batch.identities.isEmpty { try commitSSHImport(batch) }
|
||||
} catch {
|
||||
let saveError = error
|
||||
for identity in written {
|
||||
do {
|
||||
try keychain.removePassword(identity.credentialRef, storage: identity.passwordStorage)
|
||||
} catch {
|
||||
throw TermindSSHError.library(
|
||||
String(
|
||||
localized:
|
||||
"The database save and Keychain rollback both failed: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
}
|
||||
throw saveError
|
||||
}
|
||||
return SSHConnectionRoute(
|
||||
hosts: saved.map { host($0.id) ?? $0 }, usernames: usernames,
|
||||
sourceHostIDs: Dictionary(uniqueKeysWithValues: saved.map { ($0.id, $0.id) })
|
||||
)
|
||||
}
|
||||
|
||||
/// New records only. Existing credentials and host configurations are never overwritten.
|
||||
public func commitSSHImport(_ batch: SSHImportBatch) throws {
|
||||
let oldHosts = hosts
|
||||
|
||||
@@ -162,6 +162,15 @@ Resolvers batch destination/jump/proxy reads; missing secrets and availability/e
|
||||
count. First access per Identity/process writes its SwiftData row directly, without Vault journal or
|
||||
refresh; retries deduplicate. Vault sync strips this metadata and preserves it on remote application.
|
||||
|
||||
Welcome parses a bounded SSH command grammar without invoking a shell or reading OpenSSH config.
|
||||
Exact saved aliases supply metadata; command overrides create session-owned route snapshots, including
|
||||
unsaved jump Hosts and per-hop login hints. The snapshot drives authentication, Journey and reconnects;
|
||||
no lookup may replace an overridden endpoint with the original saved Host. Routes remain bounded to
|
||||
eight Hosts and reject cycles before copying aliases. Connect never saves Hosts or Identities. Explicit
|
||||
Save copies changed records and adds necessary route prerequisites in one Vault transaction, rolling
|
||||
back newly written passwords on failure. Original Hosts/Identities remain unchanged; ordinary saved
|
||||
sessions retain their existing credential-save policy. Host-key decisions remain in the live handshake.
|
||||
|
||||
### Port Forwarding
|
||||
|
||||
Port Forwarding is an independent Vault library between Keychain and Snippets. Cards share Hosts'
|
||||
|
||||
+13
-104
@@ -30,19 +30,6 @@ nonisolated enum HostOpenTarget: Hashable, Sendable {
|
||||
case sftpSession
|
||||
}
|
||||
|
||||
/// A destination typed into Welcome. The username belongs to this one attempt
|
||||
/// until the user explicitly saves it; keeping it beside the Host avoids
|
||||
/// inventing a persisted Identity just because somebody typed `user@host`.
|
||||
nonisolated struct QuickConnectTarget: Hashable, Sendable {
|
||||
var host: Host
|
||||
var username: String?
|
||||
|
||||
var loginAddress: String {
|
||||
guard let username, !username.isEmpty else { return host.displayAddress }
|
||||
return "\(username)@\(host.displayAddress)"
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide state shared by every window: the saved hosts, and the one code path
|
||||
/// for opening them.
|
||||
///
|
||||
@@ -660,34 +647,28 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens an unsaved Welcome destination without putting it into SwiftData.
|
||||
/// Its username travels with the SSH session and seeds the credential
|
||||
/// prompt when there is no saved Identity to resolve.
|
||||
@discardableResult
|
||||
func openQuickConnect(_ target: QuickConnectTarget, in window: WindowModel) -> Workspace {
|
||||
if target.isSaved {
|
||||
return open(target.host, as: .sshSession, in: window)
|
||||
}
|
||||
hosts.setStatus(.connecting, for: target.host.id)
|
||||
return window.openSSHSession(host: target.host, usernameHint: target.username)
|
||||
return window.openSSHSession(
|
||||
host: target.host, usernameHint: target.username, connectionRoute: target.route
|
||||
)
|
||||
}
|
||||
|
||||
/// Turns one Welcome destination into an ordinary saved Host. A typed
|
||||
/// username is saved as an Identity only when no existing Identity was
|
||||
/// resolved for it; authentication can then be completed by the session's
|
||||
/// Continue and Save prompt.
|
||||
@discardableResult
|
||||
func saveQuickConnect(_ target: QuickConnectTarget) -> Host {
|
||||
var host = target.host
|
||||
if host.identityID == nil,
|
||||
let username = target.username?.trimmingCharacters(in: .whitespaces),
|
||||
!username.isEmpty
|
||||
{
|
||||
let identity = Identity(name: username, username: username)
|
||||
hosts.upsert(identity)
|
||||
host.identityID = identity.id
|
||||
}
|
||||
hosts.upsert(host)
|
||||
func saveQuickConnect(_ target: QuickConnectTarget) throws -> Host {
|
||||
let route = try hosts.saveConnectionRoute(target.route)
|
||||
guard let host = route.hosts.last else { throw SSHConnectionRoute.invalidRouteError }
|
||||
return host
|
||||
}
|
||||
|
||||
func resolveQuickConnect(_ input: QuickConnectInput, locale: Locale = .current) throws -> QuickConnectTarget {
|
||||
try QuickConnectResolver.resolve(input, in: hosts, locale: locale)
|
||||
}
|
||||
|
||||
/// Mark as recently used, and flip the status dot.
|
||||
///
|
||||
/// Its own method because three code paths now open hosts — one, a batch of
|
||||
@@ -819,76 +800,4 @@ final class AppModel {
|
||||
window.hostChooser = .batchStartup
|
||||
}
|
||||
|
||||
/// Parses `user@host:port` typed into the quick-connect field. Returns nil
|
||||
/// when the input is not a plausible destination, so the field can stay
|
||||
/// quiet rather than flashing an error on every keystroke.
|
||||
///
|
||||
/// A typed login name is resolved when exactly one saved Identity uses it.
|
||||
/// It also remains on the transient target so `a@b` always connects as `a`,
|
||||
/// even when no Identity exists yet. Nothing is persisted until Save or
|
||||
/// Continue and Save is chosen.
|
||||
func parseQuickConnect(_ input: String) -> QuickConnectTarget? {
|
||||
let trimmed = input.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty, !trimmed.contains(" ") else { return nil }
|
||||
|
||||
var username: String?
|
||||
var remainder = trimmed[...]
|
||||
if let at = trimmed.lastIndex(of: "@") {
|
||||
let typed = String(trimmed[trimmed.startIndex..<at])
|
||||
guard !typed.isEmpty else { return nil }
|
||||
username = typed
|
||||
remainder = trimmed[trimmed.index(after: at)...]
|
||||
}
|
||||
|
||||
guard let (host, port) = Self.splitPort(String(remainder)) else { return nil }
|
||||
guard !host.isEmpty, host.contains(where: { $0.isLetter || $0.isNumber }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ambiguity is left unresolved rather than guessed at: two identities
|
||||
// named `root` differ in which key they present, and picking one for the
|
||||
// user would be picking a credential for them.
|
||||
let matches = username.map { name in hosts.identities.filter { $0.username == name } }
|
||||
let identityID = matches?.count == 1 ? matches?.first?.id : nil
|
||||
|
||||
return QuickConnectTarget(
|
||||
host: Host(name: host, host: host, port: port, identityID: identityID),
|
||||
username: username
|
||||
)
|
||||
}
|
||||
|
||||
/// Splits `host`, `host:port`, or `[v6]:port` into its two halves.
|
||||
///
|
||||
/// The bracket case is the reason this is not one `lastIndex(of: ":")`: a bare
|
||||
/// IPv6 literal is *all* colons, and naive splitting turns `2001:db8::1` into
|
||||
/// the host `2001:db8:` on port 1 — a plausible-looking address that is not
|
||||
/// the one anybody typed. So brackets mean "port follows", more than one colon
|
||||
/// without them means "this is IPv6, no port".
|
||||
private static func splitPort(_ input: String) -> (host: String, port: Int)? {
|
||||
if input.hasPrefix("[") {
|
||||
guard let close = input.firstIndex(of: "]") else { return nil }
|
||||
let host = String(input[input.index(after: input.startIndex)..<close])
|
||||
let rest = input[input.index(after: close)...]
|
||||
if rest.isEmpty { return (host, 22) }
|
||||
guard rest.hasPrefix(":"), let port = parsePort(rest.dropFirst()) else { return nil }
|
||||
return (host, port)
|
||||
}
|
||||
|
||||
let colons = input.filter { $0 == ":" }.count
|
||||
switch colons {
|
||||
case 0:
|
||||
return (input, 22)
|
||||
case 1:
|
||||
let parts = input.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard let port = parsePort(parts[1]) else { return nil }
|
||||
return (String(parts[0]), port)
|
||||
default:
|
||||
return (input, 22)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parsePort(_ text: Substring) -> Int? {
|
||||
guard let value = Int(text), (1...65_535).contains(value) else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ extension TransferLibraryModel: RemoteSessionHostStore {
|
||||
trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func prepareSSHConnection(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
|
||||
) throws -> SSHConnectionPreparation {
|
||||
try library.prepareSSHConnection(for: route, submissions: submissions)
|
||||
}
|
||||
func sshJumpConfigurations(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> [SSHRouteHopConfiguration] {
|
||||
try library.sshJumpConfigurations(
|
||||
for: route, submissions: submissions, temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func saveConnectionRoute(
|
||||
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?,
|
||||
authenticationHostID: Host.ID?, suppliedPassword: String?
|
||||
) throws -> SSHConnectionRoute {
|
||||
try library.saveConnectionRoute(
|
||||
route, authentication: authentication, authenticationHostID: authenticationHostID,
|
||||
suppliedPassword: suppliedPassword
|
||||
)
|
||||
}
|
||||
func transportProxy(for host: Host) throws -> SSHTransportProxy? {
|
||||
try library.transportProxy(for: host)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
|
||||
nonisolated struct QuickConnectInput: Equatable, Sendable {
|
||||
struct Endpoint: Equatable, Sendable {
|
||||
var host: String
|
||||
var username: String?
|
||||
var port: Int?
|
||||
}
|
||||
|
||||
var destination: Endpoint
|
||||
var hostname: String?
|
||||
/// nil inherits the saved route; an empty array explicitly clears it.
|
||||
var jumps: [Endpoint]?
|
||||
var isCommand: Bool
|
||||
}
|
||||
|
||||
nonisolated enum QuickConnectParseResult: Equatable, Sendable {
|
||||
case search
|
||||
case incomplete(String)
|
||||
case connection(QuickConnectInput)
|
||||
case invalid(String)
|
||||
}
|
||||
|
||||
/// A small SSH argument parser, never a shell evaluator or an OpenSSH launcher.
|
||||
nonisolated enum QuickConnectParser {
|
||||
static func isCommand(_ input: String) -> Bool {
|
||||
let first = input.split(whereSeparator: \.isWhitespace).first
|
||||
return first == "ssh" || first == "/usr/bin/ssh"
|
||||
}
|
||||
|
||||
static func parse(_ input: String, locale: Locale = .current) -> QuickConnectParseResult {
|
||||
SSHInputParser(locale: locale).parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated struct SSHInputParser {
|
||||
let locale: Locale
|
||||
|
||||
private func message(_ key: String.LocalizationValue) -> String {
|
||||
AppLocalization.string(key, locale: locale)
|
||||
}
|
||||
|
||||
func parse(_ input: String) -> QuickConnectParseResult {
|
||||
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return .search }
|
||||
let command = QuickConnectParser.isCommand(text)
|
||||
do {
|
||||
if !command {
|
||||
guard !text.contains(where: \.isWhitespace) else { return .search }
|
||||
guard let destination = try? endpoint(text) else { return .search }
|
||||
return .connection(QuickConnectInput(destination: destination, isCommand: false))
|
||||
}
|
||||
let tokens = try tokenize(text)
|
||||
var index = 1
|
||||
var destination: QuickConnectInput.Endpoint?
|
||||
var username: String?
|
||||
var port: Int?
|
||||
var hostname: String?
|
||||
var jumps: [QuickConnectInput.Endpoint]?
|
||||
var optionsEnded = false
|
||||
while index < tokens.count {
|
||||
let token = tokens[index]
|
||||
index += 1
|
||||
if token == "--", !optionsEnded {
|
||||
optionsEnded = true
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("-"), !optionsEnded {
|
||||
guard token.count >= 2 else { throw Failure.invalid(unsupported(token)) }
|
||||
let option = token[token.index(after: token.startIndex)]
|
||||
guard "plJo".contains(option) else { throw Failure.invalid(unsupported(token)) }
|
||||
let attached = String(token.dropFirst(2))
|
||||
let value: String
|
||||
if !attached.isEmpty {
|
||||
value = attached
|
||||
} else {
|
||||
guard index < tokens.count else {
|
||||
throw Failure.incomplete(message("Enter a value for \(token)."))
|
||||
}
|
||||
value = tokens[index]
|
||||
index += 1
|
||||
}
|
||||
switch option {
|
||||
case "p":
|
||||
let parsed = try parsePort(value)
|
||||
if port == nil { port = parsed }
|
||||
case "l":
|
||||
try validateUsername(value)
|
||||
if username == nil { username = value }
|
||||
case "J":
|
||||
guard jumps == nil else {
|
||||
throw Failure.invalid(
|
||||
message("Use one -J option with comma-separated jump hosts."))
|
||||
}
|
||||
jumps = try parseJumps(value)
|
||||
default:
|
||||
let pair = value.split(maxSplits: 1, whereSeparator: { $0 == "=" || $0.isWhitespace })
|
||||
guard let key = pair.first else { throw Failure.invalid(unsupported("-o")) }
|
||||
let name = key.lowercased()
|
||||
guard ["hostname", "user", "port", "proxyjump"].contains(name) else {
|
||||
throw Failure.invalid(unsupported("-o \(key)"))
|
||||
}
|
||||
guard pair.count == 2 else {
|
||||
throw Failure.incomplete(message("Enter a value for \(key)."))
|
||||
}
|
||||
let argument = pair[1].trimmingCharacters(in: .whitespaces)
|
||||
guard !argument.isEmpty else {
|
||||
throw Failure.incomplete(message("Enter a value for \(key)."))
|
||||
}
|
||||
switch name {
|
||||
case "hostname":
|
||||
try validateHost(argument)
|
||||
if hostname == nil { hostname = argument }
|
||||
case "user":
|
||||
try validateUsername(argument)
|
||||
if username == nil { username = argument }
|
||||
case "port":
|
||||
let parsed = try parsePort(argument)
|
||||
if port == nil { port = parsed }
|
||||
default:
|
||||
let parsed = try parseJumps(argument)
|
||||
if jumps == nil { jumps = parsed }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guard destination == nil else {
|
||||
throw Failure.invalid(message("Remote commands are not supported here."))
|
||||
}
|
||||
let parsed = try endpoint(token)
|
||||
destination = parsed
|
||||
if username == nil { username = parsed.username }
|
||||
}
|
||||
}
|
||||
guard var destination else {
|
||||
throw Failure.incomplete(message("Enter an SSH destination."))
|
||||
}
|
||||
destination.username = username
|
||||
destination.port = port ?? destination.port
|
||||
return .connection(
|
||||
QuickConnectInput(destination: destination, hostname: hostname, jumps: jumps, isCommand: true)
|
||||
)
|
||||
} catch Failure.incomplete(let message) {
|
||||
return .incomplete(message)
|
||||
} catch Failure.invalid(let message) {
|
||||
return .invalid(message)
|
||||
} catch {
|
||||
return .invalid(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Failure: Error {
|
||||
case incomplete(String)
|
||||
case invalid(String)
|
||||
}
|
||||
|
||||
private func unsupported(_ option: String) -> String {
|
||||
message("Unsupported SSH option: \(option).")
|
||||
}
|
||||
|
||||
private func tokenize(_ text: String) throws -> [String] {
|
||||
var tokens: [String] = []
|
||||
var token = ""
|
||||
var started = false
|
||||
var quote: Character?
|
||||
var escaping = false
|
||||
for character in text {
|
||||
if escaping {
|
||||
if quote == "\"", character != "\"", character != "\\", character != "$", character != "`" {
|
||||
token.append("\\")
|
||||
}
|
||||
token.append(character)
|
||||
escaping = false
|
||||
} else if character == "\\", quote != "'" {
|
||||
escaping = true
|
||||
started = true
|
||||
} else if let current = quote {
|
||||
if character == current { quote = nil } else { token.append(character) }
|
||||
} else if character == "'" || character == "\"" {
|
||||
quote = character
|
||||
started = true
|
||||
} else if character.isWhitespace {
|
||||
if started { tokens.append(token) }
|
||||
token = ""
|
||||
started = false
|
||||
} else if ";|&<>`$".contains(character) {
|
||||
throw Failure.invalid(message("Shell expressions are not supported here."))
|
||||
} else {
|
||||
token.append(character)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
guard quote == nil, !escaping else {
|
||||
throw Failure.incomplete(message("Finish the quoted or escaped argument."))
|
||||
}
|
||||
if started { tokens.append(token) }
|
||||
return tokens
|
||||
}
|
||||
|
||||
private func endpoint(_ text: String) throws -> QuickConnectInput.Endpoint {
|
||||
var remainder = text
|
||||
var username: String?
|
||||
if let at = remainder.lastIndex(of: "@") {
|
||||
username = String(remainder[..<at])
|
||||
if let username { try validateUsername(username) }
|
||||
remainder = String(remainder[remainder.index(after: at)...])
|
||||
}
|
||||
var port: Int?
|
||||
if remainder.hasPrefix("[") {
|
||||
guard let end = remainder.firstIndex(of: "]") else {
|
||||
throw Failure.incomplete(message("Finish the bracketed IPv6 address."))
|
||||
}
|
||||
let suffix = remainder[remainder.index(after: end)...]
|
||||
if !suffix.isEmpty {
|
||||
guard suffix.hasPrefix(":") else { throw invalidDestination() }
|
||||
port = try parsePort(String(suffix.dropFirst()))
|
||||
}
|
||||
remainder = String(remainder[remainder.index(after: remainder.startIndex)..<end])
|
||||
} else if remainder.filter({ $0 == ":" }).count == 1, let colon = remainder.firstIndex(of: ":") {
|
||||
port = try parsePort(String(remainder[remainder.index(after: colon)...]))
|
||||
remainder = String(remainder[..<colon])
|
||||
}
|
||||
try validateHost(remainder)
|
||||
return QuickConnectInput.Endpoint(host: remainder, username: username, port: port)
|
||||
}
|
||||
|
||||
private func parsePort(_ value: String) throws -> Int {
|
||||
guard !value.isEmpty, value.allSatisfy({ $0.isASCII && $0.isNumber }),
|
||||
let port = Int(value), (1...65_535).contains(port)
|
||||
else { throw Failure.invalid(message("Invalid SSH port.")) }
|
||||
return port
|
||||
}
|
||||
|
||||
private func validateUsername(_ value: String) throws {
|
||||
guard !value.isEmpty, !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
|
||||
!value.contains(where: { $0.isWhitespace || ";|&<>`$".contains($0) })
|
||||
else {
|
||||
throw invalidDestination()
|
||||
}
|
||||
}
|
||||
|
||||
private func validateHost(_ value: String) throws {
|
||||
guard !value.isEmpty, !value.hasPrefix("-"),
|
||||
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
|
||||
value.contains(where: { $0.isLetter || $0.isNumber }),
|
||||
!value.contains(where: { $0.isNewline || "/@[];|&<>`$\\".contains($0) })
|
||||
else { throw invalidDestination() }
|
||||
}
|
||||
|
||||
private func parseJumps(_ value: String) throws -> [QuickConnectInput.Endpoint] {
|
||||
if value == "none" { return [] }
|
||||
return try value.split(separator: ",", omittingEmptySubsequences: false).map { try endpoint(String($0)) }
|
||||
}
|
||||
|
||||
private func invalidDestination() -> Failure {
|
||||
.invalid(message("Invalid SSH destination."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
nonisolated struct QuickConnectTarget: Hashable, Sendable {
|
||||
var host: Host
|
||||
var route: SSHConnectionRoute
|
||||
var isSaved: Bool
|
||||
|
||||
var username: String? { route.usernames[host.id] }
|
||||
var loginAddress: String { address(for: host) }
|
||||
var routeSummary: String { route.hosts.map(address).joined(separator: " → ") }
|
||||
|
||||
private func address(for host: Host) -> String {
|
||||
guard let username = route.usernames[host.id], !username.isEmpty else { return host.displayAddress }
|
||||
return "\(username)@\(host.displayAddress)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves public Vault metadata only. Credential lookup belongs to Connect.
|
||||
@MainActor
|
||||
enum QuickConnectResolver {
|
||||
static func resolve(
|
||||
_ input: QuickConnectInput, in library: HostLibraryModel, locale: Locale = .current
|
||||
) throws -> QuickConnectTarget {
|
||||
var usernames: [Host.ID: String] = [:]
|
||||
var originals: [Host.ID: Host] = [:]
|
||||
|
||||
func resolveEndpoint(_ endpoint: QuickConnectInput.Endpoint) throws -> Host {
|
||||
let matches = input.isCommand ? library.hosts.filter { $0.name == endpoint.host } : []
|
||||
guard matches.count <= 1 else {
|
||||
throw TermindSSHError.route(
|
||||
AppLocalization.string("More than one saved host is named “\(endpoint.host)”.", locale: locale))
|
||||
}
|
||||
if matches.isEmpty, endpoint.host.contains(where: \.isWhitespace) {
|
||||
throw TermindSSHError.route(AppLocalization.string("Invalid SSH destination.", locale: locale))
|
||||
}
|
||||
var host = matches.first ?? Host(name: endpoint.host, host: endpoint.host)
|
||||
if let original = matches.first { originals[host.id] = original }
|
||||
if let port = endpoint.port { host.port = port }
|
||||
if let username = endpoint.username {
|
||||
if library.identity(for: host)?.username != username {
|
||||
let identities = library.identities.filter { $0.username == username }
|
||||
host.identityID = identities.count == 1 ? identities.first?.id : nil
|
||||
}
|
||||
usernames[host.id] = username
|
||||
} else if let username = library.identity(for: host)?.username {
|
||||
usernames[host.id] = username
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func inheritedRoute(_ host: Host) throws -> [Host] {
|
||||
guard let route = library.validatedRoute(for: host) else { throw SSHConnectionRoute.invalidRouteError }
|
||||
for node in route.dropLast() {
|
||||
originals[node.id] = node
|
||||
usernames[node.id] = library.identity(for: node)?.username
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
var destination = try resolveEndpoint(input.destination)
|
||||
if let hostname = input.hostname {
|
||||
guard !hostname.contains(where: \.isWhitespace) else {
|
||||
throw TermindSSHError.route(AppLocalization.string("Invalid SSH destination.", locale: locale))
|
||||
}
|
||||
destination.host = hostname
|
||||
}
|
||||
var nodes: [Host]
|
||||
if let jumps = input.jumps {
|
||||
nodes = []
|
||||
for (index, endpoint) in jumps.enumerated() {
|
||||
let jump = try resolveEndpoint(endpoint)
|
||||
if index == 0 {
|
||||
nodes += try inheritedRoute(jump)
|
||||
} else {
|
||||
nodes.append(jump)
|
||||
}
|
||||
}
|
||||
nodes.append(destination)
|
||||
} else {
|
||||
nodes = try inheritedRoute(destination)
|
||||
}
|
||||
// Check source IDs before copying modified records; cloning must never
|
||||
// turn an alias cycle into an apparently distinct sequence of Hosts.
|
||||
guard nodes.count <= 8, Set(nodes.map(\.id)).count == nodes.count else {
|
||||
throw SSHConnectionRoute.invalidRouteError
|
||||
}
|
||||
var resolved: [Host] = []
|
||||
var resolvedUsernames: [Host.ID: String] = [:]
|
||||
var sourceIDs: [Host.ID: Host.ID] = [:]
|
||||
for var node in nodes {
|
||||
let oldID = node.id
|
||||
node.jumpHostID = resolved.last?.id
|
||||
if let original = originals[oldID] {
|
||||
if node != original || usernames[oldID] != library.identity(for: original)?.username {
|
||||
node.id = UUID()
|
||||
}
|
||||
sourceIDs[node.id] = original.id
|
||||
}
|
||||
resolvedUsernames[node.id] = usernames[oldID]
|
||||
resolved.append(node)
|
||||
}
|
||||
let host = resolved[resolved.count - 1]
|
||||
return QuickConnectTarget(
|
||||
host: host,
|
||||
route: SSHConnectionRoute(hosts: resolved, usernames: resolvedUsernames, sourceHostIDs: sourceIDs),
|
||||
isSaved: library.host(host.id) == host
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ struct HostsPageView: ManagedCardPage {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "server.rack")
|
||||
} description: {
|
||||
Text("Save a host here, or enter user@host on the Welcome page to connect once.")
|
||||
Text("Save a host here, or enter ssh user@host on the Welcome page to connect once.")
|
||||
} actions: {
|
||||
Button("New Host") { openHostEditor(nil) }
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ final class SSHSessionModel: Identifiable {
|
||||
/// asking for credentials a second time. Cleared with the SSH connection.
|
||||
private(set) var transferConfiguration: SSHConnectionConfiguration?
|
||||
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
|
||||
var connectionRoute: SSHConnectionRoute?
|
||||
private var transientHost: Host?
|
||||
private var usernameHint: String?
|
||||
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:]
|
||||
@@ -444,7 +445,7 @@ final class SSHSessionModel: Identifiable {
|
||||
connectionAttemptID = attemptID
|
||||
if let ssh { Task { await ssh.disconnect() } }
|
||||
ssh = nil
|
||||
guard let host = store.host(hostID) ?? transientHost else {
|
||||
guard let host = connectionHost(hostID, using: store) else {
|
||||
connection.stopRecovery()
|
||||
pendingInitialCommand = nil
|
||||
connectionAttemptID = nil
|
||||
@@ -455,7 +456,7 @@ final class SSHSessionModel: Identifiable {
|
||||
isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled(
|
||||
globalSetting: DesktopSettingsKey.isShellIntegrationEnabled)
|
||||
homeResolution = isShellIntegrationEnabled ? .pending : .unavailable
|
||||
let route = store.route(for: host)
|
||||
let route = connectionRoute?.hosts ?? store.route(for: host)
|
||||
let identity = store.identity(for: host)
|
||||
let suppliedAuthentication = suppliedAuthentications[host.id]
|
||||
let username =
|
||||
@@ -559,11 +560,17 @@ final class SSHSessionModel: Identifiable {
|
||||
let expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
do {
|
||||
proxy = try store.transportProxy(for: host)
|
||||
jumps = try store.sshJumpConfigurations(
|
||||
for: host, submissions: suppliedAuthentications,
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: .allowChallenge
|
||||
)
|
||||
if let connectionRoute {
|
||||
jumps = try store.sshJumpConfigurations(
|
||||
for: connectionRoute, submissions: suppliedAuthentications,
|
||||
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
|
||||
)
|
||||
} else {
|
||||
jumps = try store.sshJumpConfigurations(
|
||||
for: host, submissions: suppliedAuthentications,
|
||||
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
|
||||
)
|
||||
}
|
||||
expectedHostKeys = try store.expectedHostKeys(
|
||||
for: host, temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: .allowChallenge
|
||||
@@ -572,7 +579,7 @@ final class SSHSessionModel: Identifiable {
|
||||
if let sshError = error as? TermindSSHError,
|
||||
sshError.shouldRequestAuthentication,
|
||||
let id = sshError.routeHostID,
|
||||
let jump = store.host(id)
|
||||
let jump = connectionHost(id, using: store)
|
||||
{
|
||||
let identity = store.identity(for: jump)
|
||||
let submitted = suppliedAuthentications[jump.id]
|
||||
@@ -582,7 +589,8 @@ final class SSHSessionModel: Identifiable {
|
||||
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
connectionAttemptID = nil
|
||||
requestAuthentication(
|
||||
for: jump, username: identity?.username ?? "",
|
||||
for: jump,
|
||||
username: submitted?.username ?? identity?.username ?? connectionRoute?.usernames[jump.id] ?? "",
|
||||
selectedKeyID: submitted?.keyID ?? identity?.keyID,
|
||||
failureMessage: sshError.localizedDescription,
|
||||
requiresPassphrase: retryNeedsPassphrase,
|
||||
@@ -626,7 +634,7 @@ final class SSHSessionModel: Identifiable {
|
||||
sshError.shouldRequestAuthentication
|
||||
{
|
||||
let authenticationHost: Host
|
||||
if let id = sshError.routeHostID, let jump = store.host(id) {
|
||||
if let id = sshError.routeHostID, let jump = connectionHost(id, using: store) {
|
||||
authenticationHost = jump
|
||||
} else {
|
||||
authenticationHost = host
|
||||
@@ -635,7 +643,7 @@ final class SSHSessionModel: Identifiable {
|
||||
let submitted = suppliedAuthentications[authenticationHost.id]
|
||||
let authenticationKeyID =
|
||||
submitted?.keyID
|
||||
?? authenticationIdentity?.keyID ?? selectedKeyID
|
||||
?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil)
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.passphrase == nil
|
||||
&& authenticationKeyID.flatMap { store.key($0) }?
|
||||
@@ -643,7 +651,9 @@ final class SSHSessionModel: Identifiable {
|
||||
let returnsToIdentity = submitted?.passphrase != nil
|
||||
requestAuthentication(
|
||||
for: authenticationHost,
|
||||
username: authenticationIdentity?.username ?? username,
|
||||
username: submitted?.username ?? authenticationIdentity?.username
|
||||
?? connectionRoute?.usernames[authenticationHost.id]
|
||||
?? (authenticationHost.id == host.id ? username : ""),
|
||||
selectedKeyID: authenticationKeyID,
|
||||
failureMessage: (credentialFailures
|
||||
+ [sshError.localizedDescription]).joined(separator: "\n"),
|
||||
@@ -671,7 +681,7 @@ final class SSHSessionModel: Identifiable {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedUsername.isEmpty else { return }
|
||||
guard let prompt = pendingAuthentication,
|
||||
var host = store.host(prompt.host.id) ?? (prompt.host.id == hostID ? transientHost : nil)
|
||||
var host = connectionHost(prompt.host.id, using: store)
|
||||
else { return }
|
||||
|
||||
if prompt.requiresPassphrase,
|
||||
@@ -694,14 +704,22 @@ final class SSHSessionModel: Identifiable {
|
||||
)
|
||||
suppliedAuthentications[host.id] = submission
|
||||
if host.id == hostID { usernameHint = trimmedUsername }
|
||||
connectionRoute?.usernames[host.id] = trimmedUsername
|
||||
pendingAuthentication = nil
|
||||
connectionJourney.resume()
|
||||
|
||||
if save {
|
||||
host = try store.saveAuthentication(
|
||||
submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password
|
||||
)
|
||||
if host.id == hostID { transientHost = host }
|
||||
if let route = connectionRoute {
|
||||
let saved = try store.saveConnectionRoute(
|
||||
route, authentication: submission, authenticationHostID: host.id, suppliedPassword: password
|
||||
)
|
||||
adoptSavedRoute(saved, replacing: route, using: store)
|
||||
} else {
|
||||
host = try store.saveAuthentication(
|
||||
submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password
|
||||
)
|
||||
if host.id == hostID { transientHost = host }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
requestAuthentication(
|
||||
@@ -895,11 +913,18 @@ final class SSHSessionModel: Identifiable {
|
||||
ssh = nil
|
||||
transferConfiguration = nil
|
||||
status = .reconnecting
|
||||
let host = store.host(hostID) ?? transientHost
|
||||
connection.presentReconnectJourney(
|
||||
host: host, using: store, purpose: .sshSession,
|
||||
username: host.flatMap { store.identity(for: $0)?.username } ?? usernameHint
|
||||
)
|
||||
let host = connectionHost(hostID, using: store)
|
||||
if let connectionRoute, let host {
|
||||
connectionJourney.begin(
|
||||
host: host, route: connectionRoute.hosts, purpose: .sshSession,
|
||||
username: connectionRoute.usernames[host.id], mode: .reconnection
|
||||
)
|
||||
} else {
|
||||
connection.presentReconnectJourney(
|
||||
host: host, using: store, purpose: .sshSession,
|
||||
username: host.flatMap { store.identity(for: $0)?.username } ?? usernameHint
|
||||
)
|
||||
}
|
||||
connection.scheduleRecovery(after: message) { [weak self, weak store] in
|
||||
guard let self, let store else { return }
|
||||
self.startConnection(using: store)
|
||||
@@ -951,9 +976,37 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? {
|
||||
connectionRoute?.host(id) ?? store.host(id) ?? (id == hostID ? transientHost : nil)
|
||||
}
|
||||
|
||||
private func adoptSavedRoute(
|
||||
_ saved: SSHConnectionRoute, replacing route: SSHConnectionRoute, using store: any RemoteSessionHostStore
|
||||
) {
|
||||
let mapping = Dictionary(uniqueKeysWithValues: zip(route.hosts, saved.hosts).map { ($0.id, $1.id) })
|
||||
suppliedAuthentications = Dictionary(
|
||||
uniqueKeysWithValues: suppliedAuthentications.map {
|
||||
(mapping[$0.key] ?? $0.key, $0.value)
|
||||
})
|
||||
temporaryHostKeys = Dictionary(
|
||||
uniqueKeysWithValues: temporaryHostKeys.map {
|
||||
(mapping[$0.key] ?? $0.key, $0.value)
|
||||
})
|
||||
if let destination = saved.hosts.last {
|
||||
if hostID != destination.id {
|
||||
store.setConnectionStatus(.disconnected, connectionID: id, hostID: hostID)
|
||||
}
|
||||
hostID = destination.id
|
||||
transientHost = destination
|
||||
usernameHint = saved.usernames[destination.id]
|
||||
}
|
||||
connectionRoute = saved
|
||||
}
|
||||
|
||||
func copyForReconnect() -> SSHSessionModel {
|
||||
let host = transientHost ?? Host(id: hostID, name: title, host: title)
|
||||
let copy = SSHSessionModel(host: host, usernameHint: usernameHint)
|
||||
copy.connectionRoute = connectionRoute
|
||||
copy.connectionCoordinator = connectionCoordinator
|
||||
copy.commandHistoryStore = commandHistoryStore
|
||||
return copy
|
||||
|
||||
@@ -837,6 +837,7 @@ final class WindowModel {
|
||||
func openSSHSession(
|
||||
host: Host,
|
||||
usernameHint: String? = nil,
|
||||
connectionRoute: SSHConnectionRoute? = nil,
|
||||
initialPath: String? = nil,
|
||||
placement: SessionOpenPlacement = .newWorkspace
|
||||
) -> Workspace {
|
||||
@@ -846,6 +847,7 @@ final class WindowModel {
|
||||
initialCommand: initialPath.flatMap(RemoteShellCommand.changeDirectory),
|
||||
recordsInitialCommandInHistory: initialPath == nil
|
||||
)
|
||||
pane.sshSession?.connectionRoute = connectionRoute
|
||||
numberPane(pane)
|
||||
|
||||
if placement == .currentWorkspace,
|
||||
|
||||
@@ -878,9 +878,12 @@ private struct PaneView: View {
|
||||
struct WelcomeView: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
@Environment(\.locale) private var locale
|
||||
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
|
||||
|
||||
@State private var query = ""
|
||||
@State private var hasSubmitted = false
|
||||
@State private var actionError: String?
|
||||
@State private var welcomeMessage = " "
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
@@ -949,18 +952,23 @@ struct WelcomeView: View {
|
||||
|
||||
private var searchField: some View {
|
||||
TermindSearchField(
|
||||
text: $query, prompt: "Search hosts or templates, or type user@host", density: .prominent
|
||||
text: $query, prompt: "Search hosts or templates, or type ssh user@host", density: .prominent
|
||||
)
|
||||
.focused($isSearchFocused)
|
||||
.onSubmit(submit)
|
||||
.onChange(of: query) { _, _ in
|
||||
hasSubmitted = false
|
||||
actionError = nil
|
||||
}
|
||||
.help("SSH options: -p port, -l user, -J jump; -o HostName, User, Port, ProxyJump.")
|
||||
}
|
||||
|
||||
private var matches: [Host] {
|
||||
query.isEmpty ? [] : app.hostLibrary.searchHosts(query)
|
||||
query.isEmpty || isSSHCommand ? [] : app.hostLibrary.searchHosts(query)
|
||||
}
|
||||
|
||||
private var matchingTemplates: [WorkspaceTemplate] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
guard !query.isEmpty, !isSSHCommand else { return [] }
|
||||
return app.hostLibrary.sortedWorkspaceTemplates.filter { template in
|
||||
template.name.localizedCaseInsensitiveContains(query)
|
||||
|| template.layout.hostIDs.contains { hostID in
|
||||
@@ -969,29 +977,57 @@ struct WelcomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// An ad-hoc destination parsed from the field, offered only when nothing
|
||||
/// saved destination or template matches — either is a better answer than
|
||||
/// treating the same text as a one-off hostname.
|
||||
private var adHoc: QuickConnectTarget? {
|
||||
guard matches.isEmpty, matchingTemplates.isEmpty else { return nil }
|
||||
return app.parseQuickConnect(query)
|
||||
private var isSSHCommand: Bool { QuickConnectParser.isCommand(query) }
|
||||
|
||||
private var resolution: Result<QuickConnectTarget, any Error>? {
|
||||
guard matches.isEmpty, matchingTemplates.isEmpty,
|
||||
case .connection(let input) = QuickConnectParser.parse(query, locale: locale)
|
||||
else { return nil }
|
||||
return Result { try app.resolveQuickConnect(input, locale: locale) }
|
||||
}
|
||||
|
||||
private var adHoc: QuickConnectTarget? { try? resolution?.get() }
|
||||
|
||||
private var connectionError: String? {
|
||||
if let actionError { return actionError }
|
||||
switch QuickConnectParser.parse(query, locale: locale) {
|
||||
case .invalid(let message): return message
|
||||
case .incomplete(let message): return hasSubmitted ? message : nil
|
||||
default: break
|
||||
}
|
||||
if case .failure(let error) = resolution { return error.localizedDescription }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Return opens the best interpretation of what was typed, so the
|
||||
/// launcher can be driven without ever reaching for the mouse.
|
||||
private func submit() {
|
||||
hasSubmitted = true
|
||||
if let template = matchingTemplates.first {
|
||||
window.openWorkspaceTemplate(template)
|
||||
} else if let host = matches.first {
|
||||
app.open(host, as: .sshSession, in: window)
|
||||
} else if let adHoc {
|
||||
app.openQuickConnect(adHoc, in: window)
|
||||
connect(adHoc)
|
||||
return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
query = ""
|
||||
}
|
||||
|
||||
private func connect(_ target: QuickConnectTarget) {
|
||||
app.openQuickConnect(target, in: window)
|
||||
query = ""
|
||||
}
|
||||
|
||||
private func save(_ target: QuickConnectTarget) {
|
||||
do {
|
||||
let host = try app.saveQuickConnect(target)
|
||||
query = host.name
|
||||
} catch {
|
||||
actionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private var actions: some View {
|
||||
@@ -1042,10 +1078,14 @@ struct WelcomeView: View {
|
||||
}
|
||||
}
|
||||
if let adHoc {
|
||||
section("Connect Once") {
|
||||
WelcomeQuickConnectRow(target: adHoc)
|
||||
section(adHoc.isSaved ? "Hosts" : "Connect Once") {
|
||||
WelcomeQuickConnectRow(target: adHoc, connect: connect, save: save)
|
||||
}
|
||||
} else if matchingTemplates.isEmpty, matches.isEmpty {
|
||||
}
|
||||
if let connectionError {
|
||||
Text(connectionError)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if adHoc == nil, !isSSHCommand, matchingTemplates.isEmpty, matches.isEmpty {
|
||||
Text("No hosts or Workspace templates match “\(query)”.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -1250,13 +1290,11 @@ private struct WelcomeHostRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `user@host` that has not been persisted. Connect keeps it
|
||||
/// transient; Save creates the Host (and username Identity) in one click.
|
||||
/// A resolved command preview. Only the explicit actions open or save it.
|
||||
private struct WelcomeQuickConnectRow: View {
|
||||
let target: QuickConnectTarget
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
var connect: (QuickConnectTarget) -> Void
|
||||
var save: (QuickConnectTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
@@ -1267,26 +1305,28 @@ private struct WelcomeQuickConnectRow: View {
|
||||
Text(target.loginAddress)
|
||||
.font(.callout.monospaced())
|
||||
.lineLimit(1)
|
||||
Text("Not saved")
|
||||
.help(target.loginAddress)
|
||||
if target.route.hosts.count > 1 {
|
||||
Text(target.routeSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.help(target.routeSummary)
|
||||
}
|
||||
Text(target.isSaved ? LocalizedStringKey("Saved") : LocalizedStringKey("Not saved"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer(minLength: Metrics.Space.m)
|
||||
Button("Connect") {
|
||||
app.openQuickConnect(target, in: window)
|
||||
Button("Connect") { connect(target) }
|
||||
if !target.isSaved {
|
||||
Button("Save") { save(target) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Button("Save") {
|
||||
app.saveQuickConnect(target)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Welcome.rowHeight,
|
||||
maxHeight: Metrics.Welcome.rowHeight,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(.vertical, Metrics.Space.s)
|
||||
.frame(maxWidth: .infinity, minHeight: Metrics.Welcome.rowHeight, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite
|
||||
struct QuickConnectParserTests {
|
||||
@Test(arguments: ["user@host:2222", "ssh -p 2222 user@host", "ssh user@host -p2222", "ssh -luser -oPort=2222 host"])
|
||||
func acceptsLoginAndPort(_ command: String) throws {
|
||||
let input = try connection(command)
|
||||
#expect(input.destination.host == "host")
|
||||
#expect(input.destination.username == "user")
|
||||
#expect(input.destination.port == 2222)
|
||||
}
|
||||
|
||||
@Test(arguments: ["[2001:db8::1]:2222", "ssh -p2222 2001:db8::1", "ssh user@[2001:db8::1]:2222"])
|
||||
func preservesIPv6(_ command: String) throws {
|
||||
let input = try connection(command)
|
||||
#expect(input.destination.host == "2001:db8::1")
|
||||
#expect(input.destination.port == 2222)
|
||||
}
|
||||
|
||||
@Test func recognizesOptionsAndShellQuotingWithoutEvaluation() throws {
|
||||
let input = try connection(
|
||||
"\tssh -o 'User deploy' -o HostName=server.invalid -J 'a@first:2200,b@[::1]' \"My Server\" ")
|
||||
#expect(input.destination.host == "My Server")
|
||||
#expect(input.destination.username == "deploy")
|
||||
#expect(input.hostname == "server.invalid")
|
||||
#expect(input.jumps?.map(\.host) == ["first", "::1"])
|
||||
#expect(input.jumps?.first?.port == 2200)
|
||||
#expect(try connection("ssh My\\ Server").destination.host == "My Server")
|
||||
#expect(try connection("ssh 'Team'\"'\"'s Server'").destination.host == "Team's Server")
|
||||
}
|
||||
|
||||
@Test func openSSHFirstAssignmentWinsAndExplicitPortOverridesShorthand() throws {
|
||||
#expect(try connection("ssh -p2222 -oPort=2223 host").destination.port == 2222)
|
||||
#expect(try connection("ssh -oPort=2223 -p2222 host").destination.port == 2223)
|
||||
#expect(try connection("ssh -lalice bob@host").destination.username == "alice")
|
||||
#expect(try connection("ssh bob@host -lalice").destination.username == "bob")
|
||||
#expect(try connection("ssh host:2222 -p2223").destination.port == 2223)
|
||||
#expect(try connection("ssh -l alice@example.org host").destination.username == "alice@example.org")
|
||||
#expect(try connection("alice@example.org@host").destination.username == "alice@example.org")
|
||||
#expect(try connection("ssh -Jfirst -oProxyJump=second host").jumps?.first?.host == "first")
|
||||
}
|
||||
|
||||
@Test func distinguishesInheritedAndClearedRoutes() throws {
|
||||
#expect(try connection("ssh host").jumps == nil)
|
||||
#expect(try connection("ssh -o ProxyJump=none host").jumps == [])
|
||||
#expect(try connection("/usr/bin/ssh -- host").destination.host == "host")
|
||||
}
|
||||
|
||||
@Test(arguments: ["", "search two words", "scp source destination", "sftp user@host"])
|
||||
func ordinaryQueriesStaySearches(_ text: String) {
|
||||
#expect(QuickConnectParser.parse(text) == .search)
|
||||
}
|
||||
|
||||
@Test(arguments: ["ssh", "ssh -p", "ssh -o User", "ssh 'unterminated", "ssh host\\", "ssh user@[::1"])
|
||||
func incompleteInputHasNoConnectableTarget(_ text: String) {
|
||||
guard case .incomplete = QuickConnectParser.parse(text) else {
|
||||
Issue.record("Expected incomplete input: \(text)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"ssh -i ~/.ssh/id_ed25519 host", "ssh -o IdentityFile=key host", "ssh -L 80:db:80 host",
|
||||
"ssh -N host", "ssh -o StrictHostKeyChecking=no host", "ssh host uptime", "ssh host;uptime",
|
||||
"ssh $(hostname)", "ssh -p0 host", "ssh -p65536 host", "ssh -p-22 host", "ssh @host",
|
||||
"ssh -Jfirst -Jsecond host", "ssh -oProxyJump=first -Jsecond host", "ssh -Jfirst, host",
|
||||
])
|
||||
func rejectsUnsupportedOrInvalidCommands(_ text: String) {
|
||||
guard case .invalid = QuickConnectParser.parse(text) else {
|
||||
Issue.record("Expected invalid input: \(text)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["zh-Hans", "zh-Hant"])
|
||||
func diagnosticsUseTheRequestedLanguage(_ language: String) {
|
||||
guard
|
||||
case .invalid(let message) = QuickConnectParser.parse(
|
||||
"ssh -i key host", locale: Locale(identifier: language))
|
||||
else {
|
||||
Issue.record("Expected an unsupported-option diagnostic")
|
||||
return
|
||||
}
|
||||
#expect(message.contains(language == "zh-Hans" ? "不支持" : "不支援"))
|
||||
}
|
||||
|
||||
private func connection(_ command: String) throws -> QuickConnectInput {
|
||||
guard case .connection(let input) = QuickConnectParser.parse(command) else {
|
||||
throw CocoaError(.coderInvalidValue)
|
||||
}
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
@Suite @MainActor
|
||||
struct QuickConnectRouteTests {
|
||||
@Test func aliasesReuseMetadataAndOverriddenUserDoesNotInheritCredentials() throws {
|
||||
let originalIdentity = Identity(name: "Production", username: "root", credentialRef: "original")
|
||||
let original = Host(name: "prod", host: "server.invalid", identityID: originalIdentity.id)
|
||||
let library = HostLibraryModel(hosts: [original], identities: [originalIdentity])
|
||||
let unchanged = try resolve("ssh prod", in: library)
|
||||
#expect(unchanged.isSaved)
|
||||
#expect(unchanged.host == original)
|
||||
let changed = try resolve("ssh -p2222 -ldeploy prod", in: library)
|
||||
#expect(!changed.isSaved)
|
||||
#expect(changed.host.id != original.id)
|
||||
#expect(changed.host.port == 2222)
|
||||
#expect(changed.host.identityID == nil)
|
||||
#expect(changed.username == "deploy")
|
||||
#expect(library.host(original.id) == original)
|
||||
#expect(library.identities == [originalIdentity])
|
||||
}
|
||||
|
||||
@Test func usernameOnUnboundAliasIsAlsoATemporaryOverride() throws {
|
||||
let original = Host(name: "prod", host: "server.invalid")
|
||||
let library = HostLibraryModel(hosts: [original])
|
||||
let target = try resolve("ssh deploy@prod", in: library)
|
||||
#expect(!target.isSaved)
|
||||
#expect(target.host.id != original.id)
|
||||
#expect(target.username == "deploy")
|
||||
}
|
||||
|
||||
@Test func aliasesRequireUniqueExactNames() throws {
|
||||
let first = Host(name: "prod", host: "first.invalid")
|
||||
let second = Host(name: "prod", host: "second.invalid")
|
||||
let library = HostLibraryModel(hosts: [first, second])
|
||||
#expect(throws: TermindSSHError.self) { try resolve("ssh prod", in: library) }
|
||||
#expect(throws: TermindSSHError.self) { try resolve("ssh -Jprod target", in: library) }
|
||||
#expect(try resolve("ssh production", in: library).host.host == "production")
|
||||
}
|
||||
|
||||
@Test func explicitJumpsReplaceTheTargetRouteAndInheritOnlyFirstHopPrerequisites() throws {
|
||||
let root = Host(name: "root", host: "root.invalid")
|
||||
let jump = Host(name: "jump", host: "jump.invalid", jumpHostID: root.id)
|
||||
let old = Host(name: "old", host: "old.invalid")
|
||||
let target = Host(name: "prod", host: "prod.invalid", jumpHostID: old.id)
|
||||
let library = HostLibraryModel(hosts: [root, jump, old, target])
|
||||
#expect(try resolve("ssh prod", in: library).route.hosts.map(\.id) == [old.id, target.id])
|
||||
let overridden = try resolve("ssh -Jjump,user@new:2222 prod", in: library)
|
||||
#expect(overridden.route.hosts.map(\.host) == ["root.invalid", "jump.invalid", "new", "prod.invalid"])
|
||||
#expect(overridden.route.isValid)
|
||||
#expect(try resolve("ssh -oProxyJump=none prod", in: library).route.hosts.count == 1)
|
||||
#expect(library.host(target.id) == target)
|
||||
#expect(throws: TermindSSHError.self) { try resolve("ssh -Jjump root", in: library) }
|
||||
#expect(throws: TermindSSHError.self) { try resolve("ssh -Ja,b,c,d,e,f,g,h destination", in: library) }
|
||||
}
|
||||
|
||||
@Test func temporaryJumpLoginCanPrepareWithoutSaving() async throws {
|
||||
let library = HostLibraryModel()
|
||||
let target = try resolve("ssh -Jbastion@jump:2222 deploy@target", in: library)
|
||||
let preparation = try library.prepareSSHConnection(for: target.route, submissions: [:])
|
||||
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
|
||||
#expect(configuration.username == "deploy")
|
||||
#expect(configuration.jumps.first?.username == "bastion")
|
||||
#expect(configuration.jumps.first?.port == 2222)
|
||||
let jumps = try library.sshJumpConfigurations(for: target.route, submissions: [:], trustPolicy: .allowChallenge)
|
||||
#expect(jumps.first?.username == "bastion")
|
||||
#expect(library.hosts.isEmpty)
|
||||
#expect(library.identities.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sessionAndReconnectCopyKeepTheSnapshot() throws {
|
||||
let source = Host(name: "prod", host: "prod.invalid")
|
||||
let library = HostLibraryModel(hosts: [source])
|
||||
let target = try resolve("ssh -p2222 -Jjump deploy@prod", in: library)
|
||||
let window = WindowModel()
|
||||
let app = AppModel(hosts: library)
|
||||
defer { app.shutdownForTermination() }
|
||||
let workspace = app.openQuickConnect(target, in: window)
|
||||
let session = try #require(workspace.focusedPane.sshSession)
|
||||
#expect(session.connectionRoute == target.route)
|
||||
#expect(session.connectionHost(session.hostID, using: library)?.port == 2222)
|
||||
#expect(session.copyForReconnect().connectionRoute == target.route)
|
||||
#expect(library.hosts == [source])
|
||||
}
|
||||
|
||||
@Test func unnamedTemporaryJumpRequestsItsOwnAuthentication() throws {
|
||||
let library = HostLibraryModel()
|
||||
let target = try resolve("ssh -Jjump deploy@target", in: library)
|
||||
let session = SSHSessionModel(host: target.host, usernameHint: target.username)
|
||||
session.connectionRoute = target.route
|
||||
session.connect(using: library)
|
||||
#expect(session.pendingAuthentication?.host.id == target.route.hosts.first?.id)
|
||||
#expect(session.pendingAuthentication?.username == "")
|
||||
session.disconnect()
|
||||
#expect(library.hosts.isEmpty)
|
||||
}
|
||||
|
||||
@Test func savingCopiesOnlyChangedRecordsAndCommitsOnce() throws {
|
||||
let jump = Host(name: "jump", host: "jump.invalid")
|
||||
let source = Host(name: "prod", host: "prod.invalid", jumpHostID: jump.id)
|
||||
let persistence = QuickConnectPersistence(snapshot: VaultSnapshot(hosts: [jump, source]))
|
||||
let library = HostLibraryModel(persistence: persistence)
|
||||
let target = try resolve("ssh -p2222 deploy@prod", in: library)
|
||||
let saved = try library.saveConnectionRoute(target.route)
|
||||
#expect(persistence.saveCount == 1)
|
||||
#expect(library.hosts.count == 3)
|
||||
#expect(saved.hosts.first?.id == jump.id)
|
||||
let destination = try #require(saved.hosts.last)
|
||||
#expect(destination.id != source.id)
|
||||
#expect(destination.name != source.name)
|
||||
#expect(library.identity(for: destination)?.username == "deploy")
|
||||
#expect(library.host(source.id) == source)
|
||||
#expect(library.validatedRoute(for: destination)?.count == 2)
|
||||
#expect(try library.saveConnectionRoute(saved) == saved)
|
||||
#expect(persistence.saveCount == 1)
|
||||
#expect(library.hosts.count == 3)
|
||||
}
|
||||
|
||||
@Test func authenticationSaveIncludesUnsavedPrerequisitesAndCopiesSharedIdentity() throws {
|
||||
let identity = Identity(name: "Root", username: "root", credentialRef: "original")
|
||||
let source = Host(name: "prod", host: "prod.invalid", identityID: identity.id)
|
||||
let secrets = InMemorySecretStore()
|
||||
try secrets.setString("old", for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
let library = HostLibraryModel(hosts: [source], identities: [identity], secrets: secrets)
|
||||
let target = try resolve("ssh -Jbastion@jump -p2222 prod", in: library)
|
||||
let submission = SSHAuthenticationSubmission(username: "root", password: "new", identityID: identity.id)
|
||||
let saved = try library.saveConnectionRoute(
|
||||
target.route, authentication: submission, authenticationHostID: target.host.id, suppliedPassword: "new")
|
||||
let destination = try #require(saved.hosts.last)
|
||||
let savedIdentity = try #require(library.identity(for: destination))
|
||||
#expect(savedIdentity.id != identity.id)
|
||||
#expect(try library.passwordCredential(for: savedIdentity) == "new")
|
||||
#expect(try library.passwordCredential(for: identity) == "old")
|
||||
#expect(library.validatedRoute(for: destination)?.count == 2)
|
||||
#expect(library.host(source.id) == source)
|
||||
}
|
||||
|
||||
@Test func continueAndSavePersistsTheRouteBeforePromptingForItsNextJump() throws {
|
||||
let library = HostLibraryModel()
|
||||
let target = try resolve("ssh -Jfirst,second deploy@target", in: library)
|
||||
let session = SSHSessionModel(host: target.host, usernameHint: target.username)
|
||||
session.connectionRoute = target.route
|
||||
session.connect(using: library)
|
||||
let firstID = try #require(session.pendingAuthentication?.host.id)
|
||||
#expect(firstID == target.route.hosts[0].id)
|
||||
session.continueAuthentication(
|
||||
identityID: nil, username: "bastion", password: nil, passphrase: nil, keyID: nil, save: true, using: library
|
||||
)
|
||||
#expect(library.hosts.count == 3)
|
||||
let saved = try #require(session.connectionRoute)
|
||||
#expect(library.validatedRoute(for: saved.hosts[2])?.count == 3)
|
||||
#expect(session.pendingAuthentication?.host.id == saved.hosts[1].id)
|
||||
#expect(saved.usernames[firstID] == "bastion")
|
||||
session.disconnect()
|
||||
#expect(session.copyForReconnect().connectionRoute == saved)
|
||||
}
|
||||
|
||||
@Test func failedRouteSaveRollsBackHostsIdentitiesAndPassword() throws {
|
||||
let persistence = QuickConnectPersistence(snapshot: VaultSnapshot())
|
||||
persistence.failsSave = true
|
||||
let secrets = QuickConnectSecrets()
|
||||
let library = HostLibraryModel(persistence: persistence, secrets: secrets)
|
||||
let target = try resolve("ssh -Ja@jump b@target", in: library)
|
||||
let submission = SSHAuthenticationSubmission(username: "b", password: "secret")
|
||||
#expect(throws: TermindSSHError.self) {
|
||||
try library.saveConnectionRoute(
|
||||
target.route, authentication: submission, authenticationHostID: target.host.id,
|
||||
suppliedPassword: "secret")
|
||||
}
|
||||
#expect(library.hosts.isEmpty)
|
||||
#expect(library.identities.isEmpty)
|
||||
#expect(secrets.writes == secrets.removals)
|
||||
#expect(!secrets.writes.isEmpty)
|
||||
persistence.failsSave = false
|
||||
let saved = try library.saveConnectionRoute(target.route)
|
||||
#expect(saved.hosts.count == 2)
|
||||
#expect(library.hosts.count == 2)
|
||||
}
|
||||
|
||||
private func resolve(_ command: String, in library: HostLibraryModel) throws -> QuickConnectTarget {
|
||||
guard case .connection(let input) = QuickConnectParser.parse(command) else {
|
||||
throw CocoaError(.coderInvalidValue)
|
||||
}
|
||||
return try QuickConnectResolver.resolve(input, in: library)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class QuickConnectPersistence: VaultPersisting {
|
||||
var snapshot: VaultSnapshot
|
||||
var failsSave = false
|
||||
var saveCount = 0
|
||||
init(snapshot: VaultSnapshot) { self.snapshot = snapshot }
|
||||
func load() throws -> VaultSnapshot { snapshot }
|
||||
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
|
||||
saveCount += 1
|
||||
if failsSave { 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: [])
|
||||
}
|
||||
}
|
||||
|
||||
private final class QuickConnectSecrets: SecretStoring, @unchecked Sendable {
|
||||
private let store = InMemorySecretStore()
|
||||
private(set) var writes: [String] = []
|
||||
private(set) var removals: [String] = []
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
||||
try store.string(for: account, storage: storage)
|
||||
}
|
||||
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
||||
writes.append(account)
|
||||
try store.setString(value, for: account, storage: storage)
|
||||
}
|
||||
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
||||
removals.append(account)
|
||||
try store.removePassword(account, storage: storage)
|
||||
}
|
||||
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil }
|
||||
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {}
|
||||
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct WelcomeQuickConnectRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func welcomePreviewsCommandsAndRejectsRemoteCommands(_ language: String) async throws {
|
||||
let library = HostLibraryModel()
|
||||
let app = AppModel(hosts: library)
|
||||
let navigation = WindowModel()
|
||||
let controller = NSHostingController(
|
||||
rootView: WelcomeView().environment(app).environment(navigation)
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(x: 0, y: 0, width: 680, height: 540),
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
controller.sizingOptions = []
|
||||
window.contentViewController = controller
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.setContentSize(CGSize(width: 680, height: 540))
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 680, height: 540)
|
||||
defer {
|
||||
app.shutdownForTermination()
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(900))
|
||||
let field = try #require(descendants(controller.view).compactMap { $0 as? NSTextField }.first { $0.isEditable })
|
||||
for (name, query) in [
|
||||
("route", "ssh -J bastion@jump.example:2200 -p 2222 deploy@server.example"),
|
||||
("unsupported", "ssh -i ~/.ssh/id_ed25519 deploy@server.example"),
|
||||
("incomplete", "ssh -p"),
|
||||
] {
|
||||
field.stringValue = query
|
||||
field.delegate?.controlTextDidChange?(
|
||||
Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
#expect(field.stringValue == query)
|
||||
#expect(navigation.workspaces.isEmpty)
|
||||
#expect(library.hosts.isEmpty)
|
||||
try capture(controller.view, name: "welcome-ssh-\(name)-\(language)")
|
||||
if name == "route" {
|
||||
window.setContentSize(CGSize(width: 360, height: 540))
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 360, height: 540)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
try capture(controller.view, name: "welcome-ssh-narrow-\(language)")
|
||||
window.setContentSize(CGSize(width: 680, height: 540))
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 680, height: 540)
|
||||
}
|
||||
}
|
||||
// Return on an invalid command must neither clear the field nor open a session.
|
||||
field.stringValue = "ssh server.example uptime"
|
||||
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
let action = try #require(field.action)
|
||||
#expect(field.sendAction(action, to: field.target))
|
||||
await Task.yield()
|
||||
#expect(navigation.workspaces.isEmpty)
|
||||
#expect(field.stringValue == "ssh server.example uptime")
|
||||
field.stringValue = "ssh -p2222 deploy@server.example"
|
||||
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(field.sendAction(action, to: field.target))
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
let session = try #require(navigation.workspaces.first?.focusedPane.sshSession)
|
||||
#expect(session.connectionRoute?.hosts.last?.port == 2222)
|
||||
#expect(field.stringValue.isEmpty)
|
||||
#expect(library.hosts.isEmpty)
|
||||
}
|
||||
|
||||
private func descendants(_ view: NSView) -> [NSView] {
|
||||
[view] + view.subviews.flatMap(descendants)
|
||||
}
|
||||
|
||||
private func capture(_ view: NSView, name: String) throws {
|
||||
guard let directory = ProcessInfo.processInfo.environment["TERMIND_RENDER_DIR"] else { return }
|
||||
let url = URL(fileURLWithPath: directory)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
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: [:]))
|
||||
try data.write(to: url.appendingPathComponent("\(name).png"))
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
F04700000000000000000001 /* SSHForwardingPumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000011 /* SSHForwardingPumpTests.swift */; };
|
||||
F04700000000000000000002 /* SSHForwardingWireTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000012 /* SSHForwardingWireTests.swift */; };
|
||||
0202B16D630CC57432748F0C /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
|
||||
03892FFD3F1E351BD2C296E4 /* TermindWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 32414DBD2157FBAD4D00056D /* TermindWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
082994E88792AF7D1F99B147 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; };
|
||||
@@ -74,6 +72,8 @@
|
||||
D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; };
|
||||
E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; };
|
||||
EF2F88E4809C845C3F1BBF63 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
|
||||
F04700000000000000000001 /* TFMTests/SSHForwardingPumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */; };
|
||||
F04700000000000000000002 /* TFMTests/SSHForwardingWireTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */; };
|
||||
F6ACDF3012081771F6C8F659 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; };
|
||||
F996BFD771BC77E67AB91FFB /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; };
|
||||
FACB579B671967F922A33CD9 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; };
|
||||
@@ -423,8 +423,6 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
F04700000000000000000011 /* SSHForwardingPumpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingPumpTests.swift; sourceTree = SOURCE_ROOT; };
|
||||
F04700000000000000000012 /* SSHForwardingWireTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingWireTests.swift; sourceTree = SOURCE_ROOT; };
|
||||
01FFEA64B9D6E13E50664FB8 /* Termind.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Termind.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
10111AE8F047FB59B318813A /* TermindApplication.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindApplication.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13753997381D54B4D92FDDD5 /* TFITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TFITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -436,6 +434,8 @@
|
||||
7A62058825D6607920F1E2A1 /* TermindDomain.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindDomain.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8C9A1D5C026A485801BF7908 /* FrameworkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FrameworkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DBE378B130478CFB00B16AA1 /* Termind.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; name = Termind.storekit; path = StoreKit/Termind.storekit; sourceTree = "<group>"; };
|
||||
F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingPumpTests.swift; sourceTree = SOURCE_ROOT; };
|
||||
F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingWireTests.swift; sourceTree = SOURCE_ROOT; };
|
||||
TRMD00000000000000000001 /* Termind.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Termind.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
TRMD00000000000000020001 /* TFMTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TFMTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
TRMD00000000000000030002 /* termind-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "termind-mcp"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -727,15 +727,6 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
F04700000000000000000010 /* Forwarding Test Support */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F04700000000000000000011 /* SSHForwardingPumpTests.swift */,
|
||||
F04700000000000000000012 /* SSHForwardingWireTests.swift */,
|
||||
);
|
||||
name = "Forwarding Test Support";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CDDB75F14F753E46BB14D972 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -750,6 +741,15 @@
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F04700000000000000000010 /* Forwarding Test Support */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */,
|
||||
F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */,
|
||||
);
|
||||
name = "Forwarding Test Support";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
TRMD00000000000000000030 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1366,8 +1366,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F04700000000000000000001 /* SSHForwardingPumpTests.swift in Sources */,
|
||||
F04700000000000000000002 /* SSHForwardingWireTests.swift in Sources */,
|
||||
F04700000000000000000001 /* TFMTests/SSHForwardingPumpTests.swift in Sources */,
|
||||
F04700000000000000000002 /* TFMTests/SSHForwardingWireTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user