From 2918b32af3d080b8dacbe8af8772ea395a34e5bb Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 21 Sep 2026 16:29:25 +0800 Subject: [PATCH] Add Host PTY environment variables --- .../HostShellIntegrationTests.swift | 25 ++++ .../PersistenceCompatibilityTests.swift | 32 ++++ Frameworks/Resources/Localizable.xcstrings | 137 +++++++++++++++--- Frameworks/TermindDomain/Hosts/Host.swift | 41 +++++- .../TermindPersistence/HostPersistence.swift | 14 +- .../TermindPersistence.swift | 2 +- .../TermindSchemaV27HostModels.swift | 96 ++++++++++++ .../HostIntegrationSection.swift | 70 ++++++++- .../SSH/SSHPTYChannel.swift | 31 ++++ Memory/ARCHITECTURE.md | 4 + .../HostEditor/MobileHostEditor.swift | 78 +++++++++- TFI/Features/Workspaces/MobileWorkspace.swift | 5 + TFM/Features/HostEditor/HostEditorView.swift | 87 ++++++++++- TFM/Features/Terminal/SSHSessionModel.swift | 5 + 14 files changed, 600 insertions(+), 27 deletions(-) create mode 100644 Frameworks/TermindPersistence/TermindSchemaV27HostModels.swift diff --git a/FrameworkTests/HostShellIntegrationTests.swift b/FrameworkTests/HostShellIntegrationTests.swift index 31812aa7..af5f4609 100644 --- a/FrameworkTests/HostShellIntegrationTests.swift +++ b/FrameworkTests/HostShellIntegrationTests.swift @@ -34,6 +34,31 @@ struct HostShellIntegrationTests { Host.self, from: JSONSerialization.data(withJSONObject: json) ) #expect(decoded.startupSnippetID == nil) + #expect(decoded.environmentVariables.isEmpty) + } + + @Test func environmentVariablesValidateAndRoundTripInOrder() throws { + let variables = [ + HostEnvironmentVariable(variable: "TERMIND_ENV", value: "production"), + HostEnvironmentVariable(variable: "EMPTY", value: ""), + ] + let host = Host(name: "Server", host: "server.invalid", environmentVariables: variables) + #expect(host.environmentVariablesAreValid) + #expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host) + #expect(PersistentHost(host).value == host) + + #expect(!HostEnvironmentVariable(variable: "1INVALID", value: "value").isValid) + #expect(!HostEnvironmentVariable(variable: "HAS SPACE", value: "value").isValid) + #expect(!HostEnvironmentVariable(variable: "VALID", value: "value\0suffix").isValid) + #expect( + !Host( + name: "Duplicate", host: "server.invalid", + environmentVariables: [ + HostEnvironmentVariable(variable: "SAME", value: "one"), + HostEnvironmentVariable(variable: "SAME", value: "two"), + ] + ).environmentVariablesAreValid + ) } @Test func savedModesSyncAndCanReturnToFollowingGlobalSettings() throws { diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index 135f8bf7..9a15438c 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -70,11 +70,14 @@ struct PersistenceCompatibilityTests { #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV24.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV25.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier }) + #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV27.versionIdentifier }) #expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self }) #expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(!TermindSchemaV26.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(TermindSchemaV26.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self }) + #expect(!TermindSchemaV27.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self }) + #expect(TermindSchemaV27.models.contains { $0 == TermindSchemaV27HostModels.PersistentHost.self }) #expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" }) #expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" }) #expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" }) @@ -109,6 +112,35 @@ struct PersistenceCompatibilityTests { #expect(host.startupSnippetID == nil) } + @Test + func version26StoreMigratesWithNoEnvironmentVariables() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Termind.store") + let oldSchema = Schema(versionedSchema: TermindSchemaV26.self) + let oldConfiguration = ModelConfiguration( + "Termind", schema: oldSchema, url: url, cloudKitDatabase: .none + ) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfiguration] + ) + oldContainer?.mainContext.insert( + TermindSchemaV26HostModels.PersistentHost( + Host(name: "Before environment variables", host: "server.invalid") + ) + ) + try oldContainer?.mainContext.save() + oldContainer = nil + + let migrated = try TermindPersistence.makeContainer(url: url) + let host = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first?.value + ) + #expect(host.name == "Before environment variables") + #expect(host.environmentVariables.isEmpty) + } + @Test func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index aff20617..c0ed454c 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2347,6 +2347,23 @@ } } }, + "Add Environment Variable" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加环境变量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入環境變數" + } + } + } + }, "Add Forwarding" : { "extractionState" : "stale", "localizations" : { @@ -9192,6 +9209,22 @@ } } }, + "Edit Environment Variable" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑环境变量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯環境變數" + } + } + } + }, "Edit Forwarding" : { "localizations" : { "zh-Hans" : { @@ -9775,6 +9808,23 @@ } } }, + "Environment Variables" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "环境变量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "環境變數" + } + } + } + }, "Error" : { "extractionState" : "manual", "localizations" : { @@ -15248,6 +15298,22 @@ } } }, + "New Environment Variable" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建环境变量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增環境變數" + } + } + } + }, "New Folder" : { "localizations" : { "zh-Hans" : { @@ -17396,6 +17462,7 @@ } }, "Organization" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -22327,22 +22394,6 @@ } } }, - "SFTP Initial Path" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "SFTP初始路径" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "SFTP初始路徑" - } - } - } - }, "SFTP Error" : { "localizations" : { "zh-Hans" : { @@ -22359,6 +22410,23 @@ } } }, + "SFTP Initial Path" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SFTP初始路径" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "SFTP初始路徑" + } + } + } + }, "SHA-256" : { "extractionState" : "stale", "localizations" : { @@ -24114,6 +24182,7 @@ } }, "Store password in" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -24130,6 +24199,7 @@ } }, "Stored in" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -25272,6 +25342,7 @@ } }, "The host could not be saved." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -26339,6 +26410,22 @@ } } }, + "The server rejected these environment variables: %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器拒绝了这些环境变量:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器拒絕了這些環境變數:%@" + } + } + } + }, "The server sent an invalid SSH identification." : { "extractionState" : "stale", "localizations" : { @@ -29281,6 +29368,22 @@ } } }, + "Variable" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "变量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "變數" + } + } + } + }, "Vault" : { "localizations" : { "zh-Hans" : { @@ -30425,4 +30528,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index b7ab54e8..0774caec 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -1,5 +1,32 @@ import Foundation +public nonisolated struct HostEnvironmentVariable: Codable, Hashable, Sendable { + public var variable: String + public var value: String + + public init(variable: String, value: String) { + self.variable = variable + self.value = value + } + + public var isValid: Bool { + guard let first = variable.utf8.first, + first == Character("_").asciiValue || first.isASCIILetter, + !variable.contains("\0"), !value.contains("\0") + else { return false } + return variable.utf8.dropFirst().allSatisfy { + $0 == Character("_").asciiValue || $0.isASCIILetter || $0.isASCIIDigit + } + } +} +nonisolated + + extension UInt8 +{ + fileprivate var isASCIILetter: Bool { (65...90).contains(self) || (97...122).contains(self) } + fileprivate var isASCIIDigit: Bool { (48...57).contains(self) } +} + public nonisolated struct HostProxy: Codable, Hashable, Sendable { public nonisolated enum ProtocolKind: String, Codable, CaseIterable, Identifiable, Sendable { case http @@ -93,6 +120,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { /// A reusable script submitted after each interactive SSH shell becomes ready. /// The body is resolved from the Snippet library for every connection attempt. public var startupSnippetID: Snippet.ID? + /// Plain-text variables requested on interactive PTY channels before the shell starts. + /// Servers may reject individual variables according to their AcceptEnv policy. + public var environmentVariables: [HostEnvironmentVariable] = [] /// Physical SSH connections available to local and dynamic forwarding on supported platforms. public var portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections /// Last distribution reported by Shell Integration for this saved Host. @@ -149,6 +179,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0, shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings, startupSnippetID: Snippet.ID? = nil, + environmentVariables: [HostEnvironmentVariable] = [], portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections ) { self.id = id @@ -166,6 +197,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { self.sortIndex = sortIndex self.shellIntegrationMode = shellIntegrationMode self.startupSnippetID = startupSnippetID + self.environmentVariables = environmentVariables self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( portForwardingMaxConnections) } @@ -174,7 +206,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { case id, name, host, port, identityID, jumpHostID, proxy case sftpChannelInitialPath = "sftpSessionInitialPath" case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode - case startupSnippetID, portForwardingMaxConnections + case startupSnippetID, environmentVariables, portForwardingMaxConnections } public init(from decoder: any Decoder) throws { @@ -196,6 +228,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode) .flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID) + environmentVariables = + try values.decodeIfPresent([HostEnvironmentVariable].self, forKey: .environmentVariables) ?? [] portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( try values.decodeIfPresent(Int.self, forKey: .portForwardingMaxConnections) ?? Self.defaultPortForwardingMaxConnections) @@ -205,6 +239,11 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { min(max(value, portForwardingMaxConnectionsRange.lowerBound), portForwardingMaxConnectionsRange.upperBound) } + public var environmentVariablesAreValid: Bool { + environmentVariables.allSatisfy(\.isValid) + && Set(environmentVariables.map(\.variable)).count == environmentVariables.count + } + } /// A user-defined folder of hosts. `parentID` is optional and self-referential, diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 9f3699c6..51cb25c7 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -755,6 +755,15 @@ public enum TermindSchemaV26: VersionedSchema { } } +public enum TermindSchemaV27: VersionedSchema { + public static let versionIdentifier = Schema.Version(27, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV26.models.filter { + $0 != TermindSchemaV26HostModels.PersistentHost.self + } + [TermindSchemaV27HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -765,7 +774,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self, TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self, TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self, - TermindSchemaV26.self, + TermindSchemaV26.self, TermindSchemaV27.self, ] } @@ -833,10 +842,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV23.self, toVersion: TermindSchemaV24.self), MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self), MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self), ] } } -public typealias PersistentHost = TermindSchemaV26HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV27HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 102b390c..b2975022 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -5,7 +5,7 @@ import SwiftData /// choose storage and own the container lifetime, never the model set. @MainActor public enum TermindPersistence { - public static let schema = Schema(versionedSchema: TermindSchemaV26.self) + public static let schema = Schema(versionedSchema: TermindSchemaV27.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV27HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV27HostModels.swift new file mode 100644 index 00000000..f726b7b2 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV27HostModels.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +/// V27 adds ordered PTY environment variables while preserving the Host entity name. +public enum TermindSchemaV27HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var port = 22 + public var identityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var lastUsedAt: Date? + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + port = value.port + identityID = value.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, + identityID: identityID, groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index 7acf9f4e..2a17e068 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -22,19 +22,25 @@ public struct HostIntegrationSection: View { public struct HostSessionSection: View { @Binding private var sftpInitialPath: String @Binding private var startupSnippetID: Snippet.ID? + @Binding private var environmentVariables: [HostEnvironmentVariable] private let snippets: [Snippet] private let snippetPackagePath: (SnippetPackage.ID?) -> String? + private let editEnvironmentVariable: (Int?) -> Void public init( sftpInitialPath: Binding, startupSnippetID: Binding = .constant(nil), + environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]), snippets: [Snippet] = [], - snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil } + snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil }, + editEnvironmentVariable: @escaping (Int?) -> Void = { _ in } ) { _sftpInitialPath = sftpInitialPath _startupSnippetID = startupSnippetID + _environmentVariables = environmentVariables self.snippets = snippets self.snippetPackagePath = snippetPackagePath + self.editEnvironmentVariable = editEnvironmentVariable } public var body: some View { @@ -63,6 +69,68 @@ public struct HostSessionSection: View { .pickerStyle(.menu) .menuOrder(.fixed) } + + Section("Environment Variables") { + ForEach(environmentVariables.indices, id: \.self) { index in + environmentVariableRow(at: index) + } + + Button { + editEnvironmentVariable(nil) + } label: { + HStack(spacing: 8) { + Text("Add Environment Variable") + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private func environmentVariableRow(at index: Int) -> some View { + #if os(iOS) + Button { + editEnvironmentVariable(index) + } label: { + environmentVariableLabel(at: index) + } + .buttonStyle(.plain) + .swipeActions { + Button("Delete", role: .destructive) { + environmentVariables.remove(at: index) + } + } + #else + HStack(spacing: 8) { + Button { + editEnvironmentVariable(index) + } label: { + environmentVariableLabel(at: index) + } + .buttonStyle(.plain) + Button("Delete", systemImage: "xmark", role: .destructive) { + environmentVariables.remove(at: index) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + #endif + } + + private func environmentVariableLabel(at index: Int) -> some View { + Text("\(environmentVariables[index].variable)=\(environmentVariables[index].value)") + .font(.body.monospaced()) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } private func snippetLabel(_ snippet: Snippet) -> String { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 26e8274f..865b0c53 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -602,9 +602,12 @@ public actor SSHPTYChannel { public typealias OutputHandler = @Sendable (Data) -> Void public typealias ExitHandler = @Sendable (Result) -> Void public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void + public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void private nonisolated let worker: SSHPTYChannelWorker private let keyboardInteractive: SSHKeyboardInteractiveHandler? + private var environmentVariables: [HostEnvironmentVariable] = [] + private var environmentRejection: EnvironmentRejectionHandler = { _ in } public init( output: @escaping OutputHandler, exit: @escaping ExitHandler, @@ -690,11 +693,21 @@ public actor SSHPTYChannel { defer { request.configuration.clearSecrets() } try await worker.connect( using: request.withKeyboardInteractiveFallback(keyboardInteractive), + environmentVariables: environmentVariables, + environmentRejection: environmentRejection, shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, progress: progress ) } + public func configureEnvironment( + _ variables: [HostEnvironmentVariable], + rejection: @escaping EnvironmentRejectionHandler = { _ in } + ) { + environmentVariables = variables + environmentRejection = rejection + } + public func write(_ data: Data) { worker.write(data) } @@ -1011,6 +1024,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { public func connect( using request: SSHConnectionRequest, + environmentVariables: [HostEnvironmentVariable], + environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler, shellIntegrationEnabled: Bool, columns: UInt16, rows: UInt16, progress: SSHRouteProgressHandler? @@ -1059,6 +1074,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ) guard pty == 0 else { throw Self.connectionError(created, prefix: "PTY request failed") } ptyResize.didCreatePTY(size: size) + var rejectedVariables: [String] = [] + for environmentVariable in environmentVariables { + let result = environmentVariable.variable.withCString { variable in + environmentVariable.value.withCString { value in + libssh2_channel_setenv_ex( + openedChannel, variable, UInt32(environmentVariable.variable.utf8.count), + value, UInt32(environmentVariable.value.utf8.count)) + } + } + if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED { + rejectedVariables.append(environmentVariable.variable) + } else if result != 0 { + throw Self.connectionError(created, prefix: "SSH environment request failed") + } + } + if !rejectedVariables.isEmpty { environmentRejection(rejectedVariables) } guard libssh2_channel_process_startup(openedChannel, "shell", 5, nil, 0) == 0 else { throw Self.connectionError(created, prefix: "Remote shell request failed") } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 6e52ce5c..cd1d2fb7 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -190,6 +190,10 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r ### Terminal behavior +- Host environment variables are ordinary Vault-synced, non-secret configuration. Interactive SSH PTY Channels send + them as SSH environment requests before starting the shell on every connection and reconnection. Server rejection is + non-fatal and reports variable names, never values, in local terminal output. Exec, SFTP, Metrics, Agent, forwarding + and background probe Channels never inherit them; there is no shell-command fallback. - SSH worker owns PTY dimensions; retain latest valid authentication-time size/pixels. EAGAIN window-change packets keep original dimensions and finish before the newest request without blocking other I/O. Disconnect clears resize state; retired auth cannot install a PTY/clear replacement dimensions. Ghostty computes grid. Resize failure is diagnostic only. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 7c84ba40..140b9dd3 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -6,12 +6,13 @@ import TermindPresentation import struct TermindDomain.Host struct MobileHostEditor: View { - private enum EditorRoute: String, Identifiable { + private enum EditorRoute: Hashable, Identifiable { case hostIdentity case proxyIdentity case group + case environmentVariable(Int?) - var id: String { rawValue } + var id: Self { self } } private enum Choice: Hashable { @@ -43,7 +44,8 @@ struct MobileHostEditor: View { canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (1...65_535).contains(draft.port) && customIdentity.isSSHValid(for: draft) - && customIdentity.isProxyValid(for: draft), + && customIdentity.isProxyValid(for: draft) + && draft.environmentVariablesAreValid, save: { try customIdentity.save(into: &draft, using: library) }, @@ -158,8 +160,10 @@ struct MobileHostEditor: View { HostSessionSection( sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, + environmentVariables: $draft.environmentVariables, snippets: library.snippets, - snippetPackagePath: library.snippetPackagePath + snippetPackagePath: library.snippetPackagePath, + editEnvironmentVariable: { editorRoute = .environmentVariable($0) } ) HostIntegrationSection(mode: $draft.shellIntegrationMode) } @@ -189,6 +193,20 @@ struct MobileHostEditor: View { MobileHostGroupEditor(app: app, group: nil, parentID: nil) { group in draft.groupID = group.id } + case .environmentVariable(let index): + MobileHostEnvironmentVariableEditor( + variable: index.map { draft.environmentVariables[$0] }, + usedVariables: Set( + draft.environmentVariables.indices.compactMap { + $0 == index ? nil : draft.environmentVariables[$0].variable + }) + ) { variable in + if let index { + draft.environmentVariables[index] = variable + } else { + draft.environmentVariables.append(variable) + } + } } } .onAppear(perform: loadCustomOnce) @@ -287,6 +305,58 @@ struct MobileHostEditor: View { } +private struct MobileHostEnvironmentVariableEditor: View { + let original: HostEnvironmentVariable + let isNew: Bool + let usedVariables: Set + let didSave: (HostEnvironmentVariable) -> Void + @State private var draft: HostEnvironmentVariable + + init( + variable: HostEnvironmentVariable?, usedVariables: Set, + didSave: @escaping (HostEnvironmentVariable) -> Void + ) { + let initial = variable ?? HostEnvironmentVariable(variable: "", value: "") + original = initial + isNew = variable == nil + self.usedVariables = usedVariables + self.didSave = didSave + _draft = State(initialValue: initial) + } + + var body: some View { + MobileEditorSheet( + title: String(localized: isNew ? "New Environment Variable" : "Edit Environment Variable"), + isDirty: draft != original, + canSave: draft.isValid && !usedVariables.contains(draft.variable), + compact: true, + save: { didSave(draft) }, + content: { + LabeledContent("Variable") { + TextField("Variable", text: $draft.variable.singleLineText) + .singleLinePaste() + .lineLimit(1) + .labelsHidden() + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .multilineTextAlignment(.trailing) + .font(.body.monospaced()) + } + LabeledContent("Value") { + TextField("Value", text: $draft.value.singleLineText) + .singleLinePaste() + .lineLimit(1) + .labelsHidden() + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .multilineTextAlignment(.trailing) + .font(.body.monospaced()) + } + } + ) + } +} + nonisolated enum MobileHostEditorPolicy { static func selectProxy( _ kind: HostProxy.ProtocolKind?, current: HostProxy? diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 95dcbd4a..3ae71ecc 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -697,6 +697,11 @@ final class MobileWorkspace: Identifiable { handlers.exit, handlers.shellIntegration) ssh = session + await session.configureEnvironment(host.environmentVariables) { variables in + let list = variables.joined(separator: ", ") + let warning = String(localized: "The server rejected these environment variables: \(list)") + backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8)) + } try await connectionServices.connectSSH( session, request, diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 0d53a3c7..ff6441b7 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -14,6 +14,7 @@ struct HostEditorView: View { case identity case proxyIdentity case group + case environmentVariable(Int?) } let hostID: Host.ID? @@ -201,8 +202,10 @@ struct HostEditorView: View { HostSessionSection( sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, + environmentVariables: $draft.environmentVariables, snippets: app.hostLibrary.snippets, - snippetPackagePath: app.hostLibrary.snippetPackagePath + snippetPackagePath: app.hostLibrary.snippetPackagePath, + editEnvironmentVariable: { route = .environmentVariable($0) } ) Section("Port Forwarding") { @@ -282,6 +285,21 @@ struct HostEditorView: View { try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) customIdentity.selectProxyIdentity(identity, on: &draft) } + case .environmentVariable(let index): + HostEnvironmentVariableEditor( + variable: index.map { draft.environmentVariables[$0] }, + usedVariables: Set( + draft.environmentVariables.indices.compactMap { + $0 == index ? nil : draft.environmentVariables[$0].variable + }), + back: closeNestedEditor + ) { variable in + if let index { + draft.environmentVariables[index] = variable + } else { + draft.environmentVariables.append(variable) + } + } } } @@ -498,6 +516,7 @@ struct HostEditorView: View { && (1...65_535).contains(draft.port) && customIdentity.isProxyValid(for: draft) && customIdentity.isSSHValid(for: draft) + && draft.environmentVariablesAreValid && (draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || draft.sftpChannelInitialPath.hasPrefix("/")) && app.hostLibrary.validatedRoute(for: draft) != nil @@ -538,3 +557,69 @@ struct HostEditorView: View { window.openPage(.hosts) } } + +private struct HostEnvironmentVariableEditor: View { + let original: HostEnvironmentVariable? + let usedVariables: Set + let back: () -> Void + let save: (HostEnvironmentVariable) -> Void + + @State private var draft: HostEnvironmentVariable + + init( + variable: HostEnvironmentVariable?, usedVariables: Set, back: @escaping () -> Void, + save: @escaping (HostEnvironmentVariable) -> Void + ) { + original = variable + self.usedVariables = usedVariables + self.back = back + self.save = save + _draft = State(initialValue: variable ?? HostEnvironmentVariable(variable: "", value: "")) + } + + var body: some View { + VaultDetailScaffold( + title: original == nil ? "New Environment Variable" : "Edit Environment Variable", + symbol: "text.line.first.and.arrowtriangle.forward", close: back, + closeLabel: "Back", closeSymbol: "chevron.backward" + ) { + Form { + Section { + LabeledContent("Variable") { + TextField("Variable", text: $draft.variable.singleLineText) + .singleLinePaste() + .lineLimit(1) + .labelsHidden() + .multilineTextAlignment(.trailing) + .font(.body.monospaced()) + } + LabeledContent("Value") { + TextField("Value", text: $draft.value.singleLineText) + .singleLinePaste() + .lineLimit(1) + .labelsHidden() + .multilineTextAlignment(.trailing) + .font(.body.monospaced()) + } + } + } + .formStyle(.grouped) + } footer: { + HStack { + Spacer() + Button("Cancel", action: back).keyboardShortcut(.cancelAction) + Button(original == nil ? "Add" : "Save") { + save(draft) + back() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .padding(Metrics.Space.l) + } + } + + private var isValid: Bool { + draft.isValid && !usedVariables.contains(draft.variable) + } +} diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 206efbc9..0cf34398 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -932,6 +932,11 @@ final class SSHSessionModel: Identifiable { ) ssh = actor do { + await actor.configureEnvironment(host.environmentVariables) { variables in + let list = variables.joined(separator: ", ") + let warning = String(localized: "The server rejected these environment variables: \(list)") + backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8)) + } try await actor.connect( using: SSHConnectionRequest( configuration: configuration,