Add Host PTY environment variables
This commit is contained in:
1 parent
46f4b17ab0
commit
2918b32af3
14 files changed
+600
-27
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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<PersistentHost>()).first?.value
|
||||
)
|
||||
#expect(host.name == "Before environment variables")
|
||||
#expect(host.environmentVariables.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
startupSnippetID: Binding<Snippet.ID?> = .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 {
|
||||
|
||||
@@ -602,9 +602,12 @@ public actor SSHPTYChannel {
|
||||
public typealias OutputHandler = @Sendable (Data) -> Void
|
||||
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> 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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ID: Hashable>: 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<String>
|
||||
let didSave: (HostEnvironmentVariable) -> Void
|
||||
@State private var draft: HostEnvironmentVariable
|
||||
|
||||
init(
|
||||
variable: HostEnvironmentVariable?, usedVariables: Set<String>,
|
||||
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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>
|
||||
let back: () -> Void
|
||||
let save: (HostEnvironmentVariable) -> Void
|
||||
|
||||
@State private var draft: HostEnvironmentVariable
|
||||
|
||||
init(
|
||||
variable: HostEnvironmentVariable?, usedVariables: Set<String>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user