Parse ssh command

This commit is contained in:
Kaede Akino committed 2026-09-19 11:06:07 +08:00
1 parent 8ed3a5159c
commit 509484ae4c
16 files changed
+1426 -177

No files matched your search

+252 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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)
}
+258
View File
@@ -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."))
}
}
+113
View File
@@ -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
)
}
}
+1 -1
View File
@@ -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) }
}
+75 -22
View File
@@ -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))
}
}
+323
View File
@@ -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"))
}
}
+15 -15
View File
@@ -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;
};