diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index b235488b..4308cf6b 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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, diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index cabf2e4a..ed009b35 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -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? diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionRoute.swift b/Frameworks/TermindApplication/Connections/SSHConnectionRoute.swift new file mode 100644 index 00000000..cc894837 --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHConnectionRoute.swift @@ -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.")) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index b0ae8e0d..9fd9c9db 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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 = [] + 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 + ) 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 diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 3b023383..c0ba203a 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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' diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index aa13802b..d1b8eb1e 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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.. (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).. Int? { - guard let value = Int(text), (1...65_535).contains(value) else { return nil } - return value - } } diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index 69ecec25..75d542c2 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -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) } diff --git a/TFM/Application/QuickConnectParser.swift b/TFM/Application/QuickConnectParser.swift new file mode 100644 index 00000000..0f862b45 --- /dev/null +++ b/TFM/Application/QuickConnectParser.swift @@ -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[.. 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.")) + } +} diff --git a/TFM/Application/QuickConnectTarget.swift b/TFM/Application/QuickConnectTarget.swift new file mode 100644 index 00000000..516a30fc --- /dev/null +++ b/TFM/Application/QuickConnectTarget.swift @@ -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 + ) + } +} diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 98b9b0d9..6af4c308 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -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) } } diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index a915ee25..fd6ca666 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 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 diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index ab2b73fe..cdd71416 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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, diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 282e3e21..e1e41478 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -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? { + 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)) } } diff --git a/TFMTests/QuickConnectTests.swift b/TFMTests/QuickConnectTests.swift new file mode 100644 index 00000000..08ed9bf0 --- /dev/null +++ b/TFMTests/QuickConnectTests.swift @@ -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 {} +} diff --git a/TFMTests/WelcomeQuickConnectRenderingTests.swift b/TFMTests/WelcomeQuickConnectRenderingTests.swift new file mode 100644 index 00000000..f51c5db7 --- /dev/null +++ b/TFMTests/WelcomeQuickConnectRenderingTests.swift @@ -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")) + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index dc3276c5..5bd88958 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; - }; CDDB75F14F753E46BB14D972 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -750,6 +741,15 @@ path = Frameworks; sourceTree = ""; }; + F04700000000000000000010 /* Forwarding Test Support */ = { + isa = PBXGroup; + children = ( + F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */, + F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */, + ); + name = "Forwarding Test Support"; + sourceTree = ""; + }; 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; };