Add charset
This commit is contained in:
1 parent
1936e88236
commit
fa734f1e1c
38 files changed
+1237
-120
No files matched your search
@@ -3,6 +3,7 @@ import SwiftData
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPersistence
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
@@ -14,8 +15,8 @@ struct HostShellIntegrationTests {
|
||||
let expected = mode == .enabled || (mode == .followGlobalSettings && global)
|
||||
#expect(mode.isEnabled(globalSetting: global) == expected)
|
||||
let host = Host(
|
||||
name: "Server", host: "server.invalid", shellIntegrationMode: mode,
|
||||
startupSnippetID: UUID()
|
||||
name: "Server", host: "server.invalid", charset: .gb18030,
|
||||
shellIntegrationMode: mode, startupSnippetID: UUID()
|
||||
)
|
||||
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host)
|
||||
#expect(PersistentHost(host).value == host)
|
||||
@@ -30,11 +31,13 @@ struct HostShellIntegrationTests {
|
||||
#expect(decoded == host)
|
||||
}
|
||||
json.removeValue(forKey: "startupSnippetID")
|
||||
json["charset"] = "future-charset"
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Host.self, from: JSONSerialization.data(withJSONObject: json)
|
||||
)
|
||||
#expect(decoded.startupSnippetID == nil)
|
||||
#expect(decoded.environmentVariables.isEmpty)
|
||||
#expect(decoded.charset == .utf8)
|
||||
}
|
||||
|
||||
@Test func environmentVariablesValidateAndRoundTripInOrder() throws {
|
||||
@@ -89,6 +92,24 @@ struct HostShellIntegrationTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func additionalConnectionSnapshotRetainsTargetCharset() {
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "server.invalid", port: 22, username: "deploy",
|
||||
credentials: [], expectedHostKeys: [], charset: .shiftJIS)
|
||||
let snapshot = SSHAdditionalConnectionSnapshot(configuration: configuration)
|
||||
#expect(snapshot.target.charset == .shiftJIS)
|
||||
}
|
||||
|
||||
@Test func charsetChangesTheAgentRouteFingerprint() throws {
|
||||
var host = Host(name: "Server", host: "server.invalid")
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
let resolver = AgentHostResolver(store: library)
|
||||
let before = try resolver.routeFingerprint(for: host.id)
|
||||
host.charset = .big5
|
||||
library.upsert(host)
|
||||
#expect(try resolver.routeFingerprint(for: host.id) != before)
|
||||
}
|
||||
|
||||
@Test func failedSavePreservesTheOriginalHostAndUploadIntent() throws {
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
let persistence = HostPersistence(context: container.mainContext)
|
||||
|
||||
@@ -71,6 +71,7 @@ struct PersistenceCompatibilityTests {
|
||||
#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(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV28.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 })
|
||||
@@ -78,6 +79,8 @@ struct PersistenceCompatibilityTests {
|
||||
#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(!TermindSchemaV28.models.contains { $0 == TermindSchemaV27HostModels.PersistentHost.self })
|
||||
#expect(TermindSchemaV28.models.contains { $0 == TermindSchemaV28HostModels.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" })
|
||||
@@ -141,6 +144,35 @@ struct PersistenceCompatibilityTests {
|
||||
#expect(host.environmentVariables.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func version27StoreMigratesWithUTF8Charset() 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: TermindSchemaV27.self)
|
||||
let oldConfiguration = ModelConfiguration(
|
||||
"Termind", schema: oldSchema, url: url, cloudKitDatabase: .none
|
||||
)
|
||||
var oldContainer: ModelContainer? = try ModelContainer(
|
||||
for: oldSchema, configurations: [oldConfiguration]
|
||||
)
|
||||
oldContainer?.mainContext.insert(
|
||||
TermindSchemaV27HostModels.PersistentHost(
|
||||
Host(name: "Before charset", 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 charset")
|
||||
#expect(host.charset == .utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
@Suite
|
||||
struct RemoteCharsetTests {
|
||||
@Test(
|
||||
arguments: [
|
||||
(RemoteCharset.utf8, "服务器🙂"),
|
||||
(.gb18030, "服务器"),
|
||||
(.big5, "伺服器"),
|
||||
(.shiftJIS, "サーバー"),
|
||||
(.eucJP, "サーバー"),
|
||||
(.eucKR, "서버"),
|
||||
(.isoLatin1, "café"),
|
||||
(.windows1252, "“café” €"),
|
||||
]
|
||||
)
|
||||
func roundTripsEverySupportedCharset(charset: RemoteCharset, value: String) throws {
|
||||
let encoded = try RemoteCharsetStreamConverter.encode(value, charset: charset)
|
||||
#expect(try RemoteCharsetStreamConverter.decode(encoded, charset: charset) == value)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decoderRetainsEveryPossibleMultibyteBoundary() throws {
|
||||
let value = "服务器"
|
||||
let encoded = try RemoteCharsetStreamConverter.encode(value, charset: .gb18030)
|
||||
for split in 0...encoded.count {
|
||||
let decoder = try RemoteCharsetStreamConverter(charset: .gb18030, direction: .decoding)
|
||||
var output = try decoder.convert(Data(encoded.prefix(split))).data
|
||||
output.append(try decoder.convert(Data(encoded.dropFirst(split))).data)
|
||||
output.append(try decoder.finish().data)
|
||||
#expect(String(data: output, encoding: .utf8) == value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func strictConversionNeverProducesReplacementText() throws {
|
||||
#expect(throws: RemoteCharsetConversionError.self) {
|
||||
_ = try RemoteCharsetStreamConverter.encode("🙂", charset: .isoLatin1)
|
||||
}
|
||||
#expect(throws: RemoteCharsetConversionError.self) {
|
||||
_ = try RemoteCharsetStreamConverter.decode(Data([0xFF]), charset: .utf8)
|
||||
}
|
||||
|
||||
let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding)
|
||||
let output = try decoder.convert(Data([0x41, 0xFF, 0x42]), recoveringInvalidInput: true)
|
||||
#expect(output.rejectedInput)
|
||||
#expect(output.data == Data("AB".utf8))
|
||||
#expect(output.data.range(of: Data("�".utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func incompleteTrailingInputIsRejectedOnlyWhenFinished() throws {
|
||||
let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding)
|
||||
#expect(try decoder.convert(Data([0xE4, 0xB8])).data.isEmpty)
|
||||
#expect(throws: RemoteCharsetConversionError.self) { _ = try decoder.finish() }
|
||||
}
|
||||
|
||||
@Test
|
||||
func shellIntegrationPayloadUsesTheRemoteCharset() throws {
|
||||
let token = "test-token"
|
||||
let command = "查看服务器"
|
||||
let encoded = try RemoteCharsetStreamConverter.encode(command, charset: .gb18030)
|
||||
let escaped = encoded.map { String(format: "%%%02X", $0) }.joined()
|
||||
var filter = RemoteCommandEventFilter(token: token, charset: .gb18030)
|
||||
let update = filter.receive(Data("\u{1B}]777;TermindCommand=\(token);\(escaped)\u{7}".utf8))
|
||||
#expect(update.events == [.commandStarted(command)])
|
||||
#expect(!update.rejectedCharsetInput)
|
||||
}
|
||||
|
||||
@Test
|
||||
func workingDirectoryFrameIsRewrittenToPercentEncodedUTF8() throws {
|
||||
let path = "/服务器"
|
||||
let encoded = try RemoteCharsetStreamConverter.encode(path, charset: .gb18030)
|
||||
let escaped = encoded.map { String(format: "%%%02X", $0) }.joined()
|
||||
var filter = RemoteOSC7CharsetFilter(charset: .gb18030)
|
||||
let output = filter.receive(Data("\u{1B}]7;file://localhost\(escaped)\u{7}".utf8))
|
||||
#expect(!output.rejectedInput)
|
||||
let expected = "\u{1B}]7;file://localhost/%E6%9C%8D%E5%8A%A1%E5%99%A8\u{7}"
|
||||
#expect(String(decoding: output.data, as: UTF8.self) == expected)
|
||||
}
|
||||
}
|
||||
@@ -1797,6 +1797,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"A remote path contains an invalid null character." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "远程路径包含无效的空字符。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "遠端路徑包含無效的空字元。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"A replacement password cannot be empty." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -4556,6 +4573,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Charset" : {
|
||||
"comment" : "A label for the charset setting.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "字符集"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "字元集"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Charset Error" : {
|
||||
"comment" : "A title for an alert that displays an error message.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "字符集错误"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "字元集錯誤"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chat" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -19601,6 +19654,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Received text is not valid %@." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "收到的文本不是有效的%@。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "收到的文字不是有效的%@。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recent Workspaces" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -24719,6 +24789,7 @@
|
||||
}
|
||||
},
|
||||
"Terminal Input" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -24917,6 +24988,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text cannot be represented using %@." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "文本无法使用%@表示。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "文字無法使用%@表示。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"The App Store could not verify this purchase." : {
|
||||
"comment" : "Error message displayed when the purchase of a pro access product fails.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
@@ -375,6 +375,7 @@ extension RemoteSessionHostStore {
|
||||
credentials: targetAuthentication.credentials,
|
||||
expectedHostKeys: snapshot.target.expectedHostKeys,
|
||||
proxy: try proxy(snapshot.target.proxy, ownerHostID: snapshot.target.id), jumps: jumps,
|
||||
charset: snapshot.target.charset,
|
||||
authenticationReference: snapshot.target.authentication
|
||||
)
|
||||
return SSHConnectionRequest(
|
||||
|
||||
@@ -34,11 +34,13 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
|
||||
public var authentication: SSHAuthenticationReference
|
||||
public var expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
public var proxy: Proxy?
|
||||
public var charset: RemoteCharset
|
||||
|
||||
public init(
|
||||
id: Host.ID, name: String, host: String, port: Int,
|
||||
authentication: SSHAuthenticationReference,
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: Proxy?
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: Proxy?,
|
||||
charset: RemoteCharset = .utf8
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -47,6 +49,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
|
||||
self.authentication = authentication
|
||||
self.expectedHostKeys = expectedHostKeys
|
||||
self.proxy = proxy
|
||||
self.charset = charset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +79,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
|
||||
host: configuration.host, port: configuration.port,
|
||||
authentication: configuration.authenticationReference,
|
||||
expectedHostKeys: configuration.expectedHostKeys,
|
||||
proxy: proxy(configuration.proxy)
|
||||
proxy: proxy(configuration.proxy), charset: configuration.charset
|
||||
)
|
||||
jumps = configuration.jumps.map {
|
||||
Node(
|
||||
|
||||
@@ -106,6 +106,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
hostID: target.host.id, host: target.host.host, port: target.host.port,
|
||||
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
|
||||
proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps,
|
||||
charset: target.host.charset,
|
||||
authenticationReference: authenticationReference(for: target)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -534,6 +534,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
trustPolicy: trustPolicy,
|
||||
accessedIdentityIDs: &accessedIdentityIDs
|
||||
),
|
||||
charset: host.charset,
|
||||
authenticationReference: additionalAuthenticationReference(
|
||||
host: host, submission: submissions[host.id]
|
||||
)
|
||||
|
||||
@@ -354,11 +354,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
|
||||
public var expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
public var proxy: SSHTransportProxy?
|
||||
public var jumps: [SSHRouteHopConfiguration] = []
|
||||
public var charset: RemoteCharset
|
||||
public var authenticationReference: SSHAuthenticationReference
|
||||
public init(
|
||||
hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials],
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil,
|
||||
jumps: [SSHRouteHopConfiguration] = [],
|
||||
charset: RemoteCharset = .utf8,
|
||||
authenticationReference: SSHAuthenticationReference? = nil
|
||||
) {
|
||||
self.hostID = hostID
|
||||
@@ -369,6 +371,7 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
|
||||
self.expectedHostKeys = expectedHostKeys
|
||||
self.proxy = proxy
|
||||
self.jumps = jumps
|
||||
self.charset = charset
|
||||
self.authenticationReference =
|
||||
authenticationReference
|
||||
?? SSHAuthenticationReference(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// The character set used by text exchanged with a remote session.
|
||||
public nonisolated enum RemoteCharset: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case utf8
|
||||
case gb18030
|
||||
case big5
|
||||
case shiftJIS
|
||||
case eucJP
|
||||
case eucKR
|
||||
case isoLatin1
|
||||
case windows1252
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .utf8: "UTF-8"
|
||||
case .gb18030: "GB18030"
|
||||
case .big5: "Big5"
|
||||
case .shiftJIS: "Shift_JIS"
|
||||
case .eucJP: "EUC-JP"
|
||||
case .eucKR: "EUC-KR"
|
||||
case .isoLatin1: "ISO-8859-1"
|
||||
case .windows1252: "Windows-1252"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
|
||||
public var flowControl: SerialFlowControl = .none
|
||||
public var enterKey: SerialEnterKey = .carriageReturn
|
||||
public var localEcho = false
|
||||
public var charset: RemoteCharset = .utf8
|
||||
|
||||
public var normalizedDevicePath: String {
|
||||
devicePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -118,7 +119,8 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
|
||||
public init(
|
||||
devicePath: String = "", deviceName: String? = nil, baudRate: Int = 9_600, dataBits: SerialDataBits = .eight,
|
||||
parity: SerialParity = .none, stopBits: SerialStopBits = .one, flowControl: SerialFlowControl = .none,
|
||||
enterKey: SerialEnterKey = .carriageReturn, localEcho: Bool = false
|
||||
enterKey: SerialEnterKey = .carriageReturn, localEcho: Bool = false,
|
||||
charset: RemoteCharset = .utf8
|
||||
) {
|
||||
self.devicePath = devicePath
|
||||
self.deviceName = deviceName
|
||||
@@ -129,6 +131,7 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
|
||||
self.flowControl = flowControl
|
||||
self.enterKey = enterKey
|
||||
self.localEcho = localEcho
|
||||
self.charset = charset
|
||||
}
|
||||
|
||||
}
|
||||
@@ -113,6 +113,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Absolute directory opened by a new SFTP channel. Empty means the
|
||||
/// authenticated user's remote home directory, resolved by SFTP at connect.
|
||||
public var sftpChannelInitialPath: String = ""
|
||||
public var charset: RemoteCharset = .utf8
|
||||
public var groupID: HostGroup.ID?
|
||||
public var tags: [String] = []
|
||||
public var colorTag: TagColor = .none
|
||||
@@ -175,6 +176,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
public init(
|
||||
id: UUID = UUID(), name: String, host: String, port: Int = 22, identityID: Identity.ID? = nil,
|
||||
jumpHostID: Host.ID? = nil, proxy: HostProxy? = nil, sftpChannelInitialPath: String = "",
|
||||
charset: RemoteCharset = .utf8,
|
||||
groupID: HostGroup.ID? = nil, tags: [String] = [], colorTag: TagColor = .none,
|
||||
detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0,
|
||||
shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings,
|
||||
@@ -190,6 +192,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
self.jumpHostID = jumpHostID
|
||||
self.proxy = proxy
|
||||
self.sftpChannelInitialPath = sftpChannelInitialPath
|
||||
self.charset = charset
|
||||
self.groupID = groupID
|
||||
self.tags = tags
|
||||
self.colorTag = colorTag
|
||||
@@ -205,6 +208,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, host, port, identityID, jumpHostID, proxy
|
||||
case sftpChannelInitialPath = "sftpSessionInitialPath"
|
||||
case charset
|
||||
case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode
|
||||
case startupSnippetID, environmentVariables, portForwardingMaxConnections
|
||||
}
|
||||
@@ -219,6 +223,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
jumpHostID = try values.decodeIfPresent(UUID.self, forKey: .jumpHostID)
|
||||
proxy = try values.decodeIfPresent(HostProxy.self, forKey: .proxy)
|
||||
sftpChannelInitialPath = try values.decode(String.self, forKey: .sftpChannelInitialPath)
|
||||
charset =
|
||||
try values.decodeIfPresent(String.self, forKey: .charset)
|
||||
.flatMap(RemoteCharset.init(rawValue:)) ?? .utf8
|
||||
groupID = try values.decodeIfPresent(UUID.self, forKey: .groupID)
|
||||
tags = try values.decode([String].self, forKey: .tags)
|
||||
colorTag = try values.decode(TagColor.self, forKey: .colorTag)
|
||||
|
||||
@@ -764,6 +764,15 @@ public enum TermindSchemaV27: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindSchemaV28: VersionedSchema {
|
||||
public static let versionIdentifier = Schema.Version(28, 0, 0)
|
||||
public static var models: [any PersistentModel.Type] {
|
||||
TermindSchemaV27.models.filter {
|
||||
$0 != TermindSchemaV27HostModels.PersistentHost.self
|
||||
} + [TermindSchemaV28HostModels.PersistentHost.self]
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
public static var schemas: [any VersionedSchema.Type] {
|
||||
[
|
||||
@@ -774,7 +783,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, TermindSchemaV27.self,
|
||||
TermindSchemaV26.self, TermindSchemaV27.self, TermindSchemaV28.self,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -843,10 +852,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV27.self, toVersion: TermindSchemaV28.self),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public typealias PersistentHost = TermindSchemaV27HostModels.PersistentHost
|
||||
public typealias PersistentHost = TermindSchemaV28HostModels.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: TermindSchemaV27.self)
|
||||
public static let schema = Schema(versionedSchema: TermindSchemaV28.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,99 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// V28 adds the remote text charset while preserving the Host entity name.
|
||||
public enum TermindSchemaV28HostModels {
|
||||
@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 charset = RemoteCharset.utf8.rawValue
|
||||
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
|
||||
charset = value.charset.rawValue
|
||||
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, charset: RemoteCharset(rawValue: charset) ?? .utf8,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public struct HostIntegrationSection: View {
|
||||
}
|
||||
|
||||
public struct HostSessionSection: View {
|
||||
@Binding private var charset: RemoteCharset
|
||||
@Binding private var sftpInitialPath: String
|
||||
@Binding private var startupSnippetID: Snippet.ID?
|
||||
@Binding private var environmentVariables: [HostEnvironmentVariable]
|
||||
@@ -28,6 +29,7 @@ public struct HostSessionSection: View {
|
||||
private let editEnvironmentVariable: (Int?) -> Void
|
||||
|
||||
public init(
|
||||
charset: Binding<RemoteCharset>,
|
||||
sftpInitialPath: Binding<String>,
|
||||
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
|
||||
environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]),
|
||||
@@ -35,6 +37,7 @@ public struct HostSessionSection: View {
|
||||
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil },
|
||||
editEnvironmentVariable: @escaping (Int?) -> Void = { _ in }
|
||||
) {
|
||||
_charset = charset
|
||||
_sftpInitialPath = sftpInitialPath
|
||||
_startupSnippetID = startupSnippetID
|
||||
_environmentVariables = environmentVariables
|
||||
@@ -45,6 +48,24 @@ public struct HostSessionSection: View {
|
||||
|
||||
public var body: some View {
|
||||
Section("Session") {
|
||||
Picker("Charset", selection: $charset) {
|
||||
ForEach(RemoteCharset.allCases) { charset in
|
||||
Text(charset.displayName).tag(charset)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
Picker("Startup Snippet", selection: $startupSnippetID) {
|
||||
Text("None").tag(Snippet.ID?.none)
|
||||
if !snippets.isEmpty {
|
||||
Divider()
|
||||
ForEach(snippets) { snippet in
|
||||
Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
LabeledContent("SFTP Initial Path") {
|
||||
TextField("SFTP Initial Path", text: $sftpInitialPath.singleLineText, prompt: Text("~"))
|
||||
.singleLinePaste()
|
||||
@@ -57,17 +78,6 @@ public struct HostSessionSection: View {
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
}
|
||||
Picker("Startup Snippet", selection: $startupSnippetID) {
|
||||
Text("None").tag(Snippet.ID?.none)
|
||||
if !snippets.isEmpty {
|
||||
Divider()
|
||||
ForEach(snippets) { snippet in
|
||||
Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
}
|
||||
|
||||
Section("Environment Variables") {
|
||||
|
||||
@@ -354,7 +354,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild
|
||||
let hostID = try arguments.uuid("host_id")
|
||||
let command = try arguments.string("command")
|
||||
var input = arguments.optionalString("stdin").map {
|
||||
SSHCommandInput.plain(Data($0.utf8))
|
||||
SSHCommandInput.text($0)
|
||||
}
|
||||
if let rawHandle = arguments.optionalString("secret_handle") {
|
||||
guard let handleID = UUID(uuidString: rawHandle) else {
|
||||
|
||||
@@ -480,7 +480,8 @@ public final class AgentHostResolver: Sendable {
|
||||
}
|
||||
return "temporary:\(id.uuidString)"
|
||||
}
|
||||
return store.route(for: host).map { node in
|
||||
let route = store.route(for: host)
|
||||
return route.enumerated().map { index, node in
|
||||
let identity = store.identity(for: node)
|
||||
let proxy = node.proxy.map {
|
||||
"\($0.kind.rawValue)://\($0.username)@\($0.hostname):\($0.port)"
|
||||
@@ -490,6 +491,7 @@ public final class AgentHostResolver: Sendable {
|
||||
node.id.uuidString, node.host, String(node.port),
|
||||
identity?.id.uuidString ?? "", identity?.username ?? "",
|
||||
proxy ?? "", keys.joined(separator: ","),
|
||||
index == route.count - 1 ? node.charset.rawValue : "",
|
||||
].joined(separator: "\u{1F}")
|
||||
}.joined(separator: "\u{1E}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
nonisolated extension RemoteCharset {
|
||||
fileprivate var iconvName: String {
|
||||
switch self {
|
||||
case .utf8: "UTF-8"
|
||||
case .gb18030: "GB18030"
|
||||
case .big5: "BIG5"
|
||||
case .shiftJIS: "SHIFT_JIS"
|
||||
case .eucJP: "EUC-JP"
|
||||
case .eucKR: "EUC-KR"
|
||||
case .isoLatin1: "ISO-8859-1"
|
||||
case .windows1252: "WINDOWS-1252"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated struct RemoteCharsetConversionError: LocalizedError, Sendable {
|
||||
public enum Direction: Sendable { case encoding, decoding }
|
||||
|
||||
public var charset: RemoteCharset
|
||||
public var direction: Direction
|
||||
|
||||
public init(charset: RemoteCharset, direction: Direction) {
|
||||
self.charset = charset
|
||||
self.direction = direction
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch direction {
|
||||
case .encoding:
|
||||
String(localized: "Text cannot be represented using \(charset.displayName).")
|
||||
case .decoding:
|
||||
String(localized: "Received text is not valid \(charset.displayName).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stateful, strict iconv adapter. Callers choose whether malformed source
|
||||
/// bytes fail the operation or are discarded so an interactive stream can stay open.
|
||||
public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable {
|
||||
public enum Direction: Sendable { case encoding, decoding }
|
||||
|
||||
public struct Output: Sendable {
|
||||
public var data: Data
|
||||
public var rejectedInput: Bool
|
||||
}
|
||||
|
||||
private struct Conversion {
|
||||
var output: Data
|
||||
var remainder: Data
|
||||
var rejectedInput: Bool
|
||||
}
|
||||
|
||||
public let charset: RemoteCharset
|
||||
public let direction: Direction
|
||||
|
||||
private let descriptor: iconv_t
|
||||
private let lock = NSLock()
|
||||
private var pending = Data()
|
||||
|
||||
public init(charset: RemoteCharset, direction: Direction) throws {
|
||||
self.charset = charset
|
||||
self.direction = direction
|
||||
let source = direction == .encoding ? RemoteCharset.utf8.iconvName : charset.iconvName
|
||||
let destination = direction == .encoding ? charset.iconvName : RemoteCharset.utf8.iconvName
|
||||
descriptor = iconv_open(destination, source)
|
||||
guard descriptor != iconv_t(bitPattern: -1) else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
}
|
||||
|
||||
deinit { iconv_close(descriptor) }
|
||||
|
||||
/// Converts one stream fragment. Incomplete source characters are retained.
|
||||
/// With recovery enabled, invalid source bytes are discarded without emitting
|
||||
/// replacement characters and `rejectedInput` is set.
|
||||
public func convert(_ data: Data, recoveringInvalidInput: Bool = false) throws -> Output {
|
||||
try lock.withLock {
|
||||
try convertLocked(data, recoveringInvalidInput: recoveringInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
private func convertLocked(_ data: Data, recoveringInvalidInput: Bool) throws -> Output {
|
||||
var source = pending
|
||||
source.append(data)
|
||||
pending.removeAll(keepingCapacity: true)
|
||||
do {
|
||||
let converted = try convert(source, final: false, recoveringInvalidInput: recoveringInvalidInput)
|
||||
pending = converted.remainder
|
||||
return Output(data: converted.output, rejectedInput: converted.rejectedInput)
|
||||
} catch {
|
||||
pending.removeAll(keepingCapacity: false)
|
||||
reset()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Finishes the stream. An incomplete trailing character is rejected.
|
||||
public func finish(recoveringInvalidInput: Bool = false) throws -> Output {
|
||||
try lock.withLock {
|
||||
try finishLocked(recoveringInvalidInput: recoveringInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishLocked(recoveringInvalidInput: Bool) throws -> Output {
|
||||
defer {
|
||||
pending.removeAll(keepingCapacity: false)
|
||||
reset()
|
||||
}
|
||||
let converted = try convert(pending, final: true, recoveringInvalidInput: recoveringInvalidInput)
|
||||
return Output(data: converted.output, rejectedInput: converted.rejectedInput)
|
||||
}
|
||||
|
||||
public static func encode(_ value: String, charset: RemoteCharset) throws -> Data {
|
||||
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding)
|
||||
var output = try converter.convert(Data(value.utf8)).data
|
||||
output.append(try converter.finish().data)
|
||||
return output
|
||||
}
|
||||
|
||||
public static func decode(_ data: Data, charset: RemoteCharset) throws -> String {
|
||||
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
|
||||
var output = try converter.convert(data).data
|
||||
output.append(try converter.finish().data)
|
||||
guard let value = String(data: output, encoding: .utf8) else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: .decoding)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private var errorDirection: RemoteCharsetConversionError.Direction {
|
||||
direction == .encoding ? .encoding : .decoding
|
||||
}
|
||||
|
||||
private func convert(
|
||||
_ data: Data, final: Bool, recoveringInvalidInput: Bool
|
||||
) throws -> Conversion {
|
||||
var bytes = Array(data)
|
||||
defer { _ = bytes.withUnsafeMutableBytes { $0.initializeMemory(as: UInt8.self, repeating: 0) } }
|
||||
var output = Data()
|
||||
var rejectedInput = false
|
||||
var remainder = Data()
|
||||
|
||||
try bytes.withUnsafeMutableBytes { rawBuffer in
|
||||
var input = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
|
||||
var inputCount = rawBuffer.count
|
||||
|
||||
while inputCount > 0 {
|
||||
var chunk = [CChar](repeating: 0, count: max(256, min(64 * 1024, inputCount * 4 + 16)))
|
||||
let result: Int = chunk.withUnsafeMutableBufferPointer { buffer in
|
||||
var destination = buffer.baseAddress
|
||||
var destinationCount = buffer.count
|
||||
let result = iconv(descriptor, &input, &inputCount, &destination, &destinationCount)
|
||||
output.append(
|
||||
Data(bytes: buffer.baseAddress!, count: buffer.count - destinationCount)
|
||||
)
|
||||
return result
|
||||
}
|
||||
if result != -1 { continue }
|
||||
switch errno {
|
||||
case E2BIG:
|
||||
continue
|
||||
case EINVAL where !final:
|
||||
if let input, inputCount > 0 {
|
||||
remainder = Data(bytes: input, count: inputCount)
|
||||
}
|
||||
inputCount = 0
|
||||
case EINVAL, EILSEQ:
|
||||
guard recoveringInvalidInput, input != nil, inputCount > 0 else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
rejectedInput = true
|
||||
self.reset()
|
||||
inputCount -= 1
|
||||
// iconv updates `input`; advance past the rejected source byte.
|
||||
input = input?.advanced(by: 1)
|
||||
default:
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if final, !remainder.isEmpty {
|
||||
if recoveringInvalidInput {
|
||||
rejectedInput = true
|
||||
remainder.removeAll()
|
||||
} else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
}
|
||||
return Conversion(output: output, remainder: remainder, rejectedInput: rejectedInput)
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
_ = iconv(descriptor, nil, nil, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites Shell Integration OSC 7 paths from the remote charset into a
|
||||
/// percent-encoded UTF-8 payload before Ghostty parses them.
|
||||
nonisolated struct RemoteOSC7CharsetFilter: Sendable {
|
||||
private static let maximumPayloadBytes = 64 * 1024
|
||||
struct Output: Sendable {
|
||||
var data = Data()
|
||||
var rejectedInput = false
|
||||
}
|
||||
|
||||
private static let prefix = Data("\u{1B}]7;file://localhost".utf8)
|
||||
private let charset: RemoteCharset
|
||||
private var buffer = Data()
|
||||
|
||||
init(charset: RemoteCharset) { self.charset = charset }
|
||||
|
||||
mutating func receive(_ data: Data) -> Output {
|
||||
buffer.append(data)
|
||||
var output = Output()
|
||||
while let range = buffer.range(of: Self.prefix) {
|
||||
if range.lowerBound > buffer.startIndex {
|
||||
output.data.append(buffer[..<range.lowerBound])
|
||||
}
|
||||
guard let end = buffer[range.upperBound...].firstIndex(of: 0x07) else {
|
||||
if buffer.distance(from: range.upperBound, to: buffer.endIndex) > Self.maximumPayloadBytes {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
output.rejectedInput = true
|
||||
} else {
|
||||
buffer = Data(buffer[range.lowerBound...])
|
||||
}
|
||||
return output
|
||||
}
|
||||
let payload = Data(buffer[range.upperBound..<end])
|
||||
if let rewritten = rewrite(payload) {
|
||||
output.data.append(Self.prefix)
|
||||
output.data.append(rewritten)
|
||||
output.data.append(0x07)
|
||||
} else {
|
||||
output.rejectedInput = true
|
||||
}
|
||||
buffer = Data(buffer[buffer.index(after: end)...])
|
||||
}
|
||||
|
||||
let retained = longestPrefixSuffix()
|
||||
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retained)
|
||||
output.data.append(buffer[..<visibleEnd])
|
||||
buffer = Data(buffer[visibleEnd...])
|
||||
return output
|
||||
}
|
||||
|
||||
mutating func finish() -> Data {
|
||||
defer { buffer.removeAll(keepingCapacity: false) }
|
||||
return buffer
|
||||
}
|
||||
|
||||
private func rewrite(_ payload: Data) -> Data? {
|
||||
let bytes = Array(payload)
|
||||
var decoded = Data()
|
||||
var index = 0
|
||||
while index < bytes.count {
|
||||
if bytes[index] == 0x25, index + 2 < bytes.count,
|
||||
let high = hex(bytes[index + 1]), let low = hex(bytes[index + 2])
|
||||
{
|
||||
decoded.append(high << 4 | low)
|
||||
index += 3
|
||||
} else {
|
||||
decoded.append(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
guard let path = try? RemoteCharsetStreamConverter.decode(decoded, charset: charset) else { return nil }
|
||||
var encoded = Data()
|
||||
for byte in path.utf8 {
|
||||
switch byte {
|
||||
case 0x2D, 0x2E, 0x2F, 0x30...0x39, 0x41...0x5A, 0x5F, 0x61...0x7A, 0x7E:
|
||||
encoded.append(byte)
|
||||
default:
|
||||
encoded.append(0x25)
|
||||
let digits = Array("0123456789ABCDEF".utf8)
|
||||
encoded.append(digits[Int(byte >> 4)])
|
||||
encoded.append(digits[Int(byte & 0x0F)])
|
||||
}
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
private func longestPrefixSuffix() -> Int {
|
||||
guard !buffer.isEmpty else { return 0 }
|
||||
let maximum = min(buffer.count, Self.prefix.count - 1)
|
||||
guard maximum > 0 else { return 0 }
|
||||
for count in stride(from: maximum, through: 1, by: -1) {
|
||||
if buffer.suffix(count).elementsEqual(Self.prefix.prefix(count)) { return count }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func hex(_ byte: UInt8) -> UInt8? {
|
||||
switch byte {
|
||||
case 0x30...0x39: byte - 0x30
|
||||
case 0x41...0x46: byte - 0x41 + 10
|
||||
case 0x61...0x66: byte - 0x61 + 10
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,21 +122,25 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
private nonisolated let driver: SFTPChannelDriver
|
||||
private let id: UUID
|
||||
private var disconnected = false
|
||||
private let charset: RemoteCharset
|
||||
|
||||
static func open(using driver: SFTPChannelDriver) async throws -> any SFTPChannelProtocol {
|
||||
static func open(
|
||||
using driver: SFTPChannelDriver, charset: RemoteCharset
|
||||
) async throws -> any SFTPChannelProtocol {
|
||||
let id = try await driver.open()
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
return SFTPChannel(driver: driver, id: id)
|
||||
return SFTPChannel(driver: driver, id: id, charset: charset)
|
||||
} catch {
|
||||
await driver.close(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private init(driver: SFTPChannelDriver, id: UUID) {
|
||||
private init(driver: SFTPChannelDriver, id: UUID, charset: RemoteCharset) {
|
||||
self.driver = driver
|
||||
self.id = id
|
||||
self.charset = charset
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -146,7 +150,7 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
}
|
||||
|
||||
func openSiblingChannel() async throws -> any SFTPChannelProtocol {
|
||||
try await Self.open(using: driver)
|
||||
try await Self.open(using: driver, charset: charset)
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
@@ -156,26 +160,29 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
}
|
||||
|
||||
func homeDirectory() async throws -> String {
|
||||
try await driver.perform(id) { libssh2Session, sftp in
|
||||
let source = "."
|
||||
let source = try encodePath(".")
|
||||
let charset = charset
|
||||
let value: String = try await driver.perform(id) { libssh2Session, sftp in
|
||||
var target = [CChar](repeating: 0, count: 4 * 1024)
|
||||
let count = source.withCString {
|
||||
let count = source.withUnsafeBytes {
|
||||
libssh2_sftp_symlink_ex(
|
||||
sftp, $0, UInt32(source.utf8.count), &target, UInt32(target.count),
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(source.count),
|
||||
&target, UInt32(target.count),
|
||||
Int32(LIBSSH2_SFTP_REALPATH))
|
||||
}
|
||||
if count == LIBSSH2_ERROR_EAGAIN { return .again }
|
||||
guard count > 0 else {
|
||||
throw Self.error(libssh2Session, sftp, "Unable to resolve the remote home directory")
|
||||
}
|
||||
return .value(
|
||||
RemotePath.normalized(
|
||||
String(decoding: target.prefix(Int(count)).map { UInt8(bitPattern: $0) }, as: UTF8.self)))
|
||||
let bytes = Data(target.prefix(Int(count)).map { UInt8(bitPattern: $0) })
|
||||
return .value(RemotePath.normalized(try Self.decodePath(bytes, charset: charset)))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func listDirectory(at path: String) async throws -> [RemoteFile] {
|
||||
let handle = try await openHandle(path: path, flags: 0, mode: 0, type: Int32(LIBSSH2_SFTP_OPENDIR))
|
||||
let charset = charset
|
||||
var files: [RemoteFile] = []
|
||||
do {
|
||||
while true {
|
||||
@@ -187,8 +194,8 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
if count == LIBSSH2_ERROR_EAGAIN { return .again }
|
||||
guard count >= 0 else { throw Self.error(libssh2Session, sftp, "Unable to read \(path)") }
|
||||
guard count > 0 else { return .value(nil) }
|
||||
let filename = String(
|
||||
decoding: name.prefix(Int(count)).map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
||||
let filename = try Self.decodePath(
|
||||
Data(name.prefix(Int(count)).map { UInt8(bitPattern: $0) }), charset: charset)
|
||||
return .value(
|
||||
Self.remoteFile(
|
||||
path: RemotePath.join(path, filename), name: filename,
|
||||
@@ -209,17 +216,20 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
}
|
||||
|
||||
func attributes(at path: String) async throws -> RemoteFile? {
|
||||
try await driver.perform(id) { libssh2Session, sftp in
|
||||
let pathBytes = try encodePath(path)
|
||||
let value: RemoteFile? = try await driver.perform(id) { libssh2Session, sftp in
|
||||
var attributes = LIBSSH2_SFTP_ATTRIBUTES()
|
||||
let result = path.withCString {
|
||||
let result = pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_stat_ex(
|
||||
sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes)
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count),
|
||||
Int32(LIBSSH2_SFTP_LSTAT), &attributes)
|
||||
}
|
||||
if result == LIBSSH2_ERROR_EAGAIN { return .again }
|
||||
if result == 0 { return .value(Self.remoteFile(path: path, attributes: attributes)) }
|
||||
if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return .value(nil) }
|
||||
throw Self.error(libssh2Session, sftp, "Unable to inspect \(path)")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readFile(at path: String, maximumBytes: Int) async throws -> Data {
|
||||
@@ -268,39 +278,49 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
}
|
||||
|
||||
func setPermissions(at path: String, to permissions: UInt32) async throws {
|
||||
let pathBytes = try encodePath(path)
|
||||
try await performStatus(prefix: "Unable to change permissions for \(path)") { sftp in
|
||||
var attributes = LIBSSH2_SFTP_ATTRIBUTES()
|
||||
attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS)
|
||||
attributes.permissions = UInt(permissions & 0o7777)
|
||||
return path.withCString {
|
||||
return pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_stat_ex(
|
||||
sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_SETSTAT), &attributes)
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count),
|
||||
Int32(LIBSSH2_SFTP_SETSTAT), &attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectory(at path: String) async throws {
|
||||
let pathBytes = try encodePath(path)
|
||||
try await performStatus(prefix: "Unable to create \(path)") { sftp in
|
||||
path.withCString { libssh2_sftp_mkdir_ex(sftp, $0, UInt32(path.utf8.count), 0o755) }
|
||||
pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_mkdir_ex(
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rename(from source: String, to destination: String, overwrite: Bool) async throws {
|
||||
let sourceBytes = try encodePath(source)
|
||||
let destinationBytes = try encodePath(destination)
|
||||
let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0
|
||||
let _: Void = try await driver.perform(id) { libssh2Session, sftp in
|
||||
let result = source.withCString { sourceBuffer in
|
||||
destination.withCString { destinationBuffer in
|
||||
SFTPRenameOperation.perform(
|
||||
let result = sourceBytes.withUnsafeBytes { sourceBuffer in
|
||||
return destinationBytes.withUnsafeBytes { destinationBuffer in
|
||||
let sourcePointer = sourceBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
|
||||
let destinationPointer = destinationBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
|
||||
return SFTPRenameOperation.perform(
|
||||
overwrite: overwrite,
|
||||
posixRename: {
|
||||
libssh2_sftp_posix_rename_ex(
|
||||
sftp, sourceBuffer, source.utf8.count,
|
||||
destinationBuffer, destination.utf8.count)
|
||||
sftp, sourcePointer, sourceBytes.count,
|
||||
destinationPointer, destinationBytes.count)
|
||||
},
|
||||
standardRename: {
|
||||
libssh2_sftp_rename_ex(
|
||||
sftp, sourceBuffer, UInt32(source.utf8.count), destinationBuffer,
|
||||
UInt32(destination.utf8.count), flags)
|
||||
sftp, sourcePointer, UInt32(sourceBytes.count), destinationPointer,
|
||||
UInt32(destinationBytes.count), flags)
|
||||
},
|
||||
lastStatus: { libssh2_sftp_last_error(sftp) })
|
||||
}
|
||||
@@ -312,14 +332,22 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
}
|
||||
|
||||
func removeFile(at path: String) async throws {
|
||||
let pathBytes = try encodePath(path)
|
||||
try await performStatus(prefix: "Unable to delete \(path)") { sftp in
|
||||
path.withCString { libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count)) }
|
||||
pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_unlink_ex(
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirectory(at path: String) async throws {
|
||||
let pathBytes = try encodePath(path)
|
||||
try await performStatus(prefix: "Unable to delete \(path)") { sftp in
|
||||
path.withCString { libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count)) }
|
||||
pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_rmdir_ex(
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,9 +728,12 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
private func openHandle(
|
||||
path: String, flags: UInt, mode: Int, type: Int32
|
||||
) async throws -> SFTPChannelHandle {
|
||||
try await driver.perform(id) { libssh2Session, sftp in
|
||||
let opened = path.withCString {
|
||||
libssh2_sftp_open_ex(sftp, $0, UInt32(path.utf8.count), flags, mode, type)
|
||||
let pathBytes = try encodePath(path)
|
||||
return try await driver.perform(id) { libssh2Session, sftp in
|
||||
let opened = pathBytes.withUnsafeBytes {
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
UInt32(pathBytes.count), flags, mode, type)
|
||||
}
|
||||
if let opened { return .value(SFTPChannelHandle(opened)) }
|
||||
if libssh2_session_last_errno(libssh2Session) == LIBSSH2_ERROR_EAGAIN { return .again }
|
||||
@@ -816,10 +847,12 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
let flags = UInt(
|
||||
LIBSSH2_FXF_READ | LIBSSH2_FXF_WRITE
|
||||
| (continuing ? 0 : (LIBSSH2_FXF_CREAT | LIBSSH2_FXF_EXCL)))
|
||||
let temporaryBytes = try encodePath(temporary)
|
||||
let context: SFTPChannelWriteContext = try await driver.perform(id) { libssh2Session, sftp in
|
||||
let handle = temporary.withCString {
|
||||
let handle = temporaryBytes.withUnsafeBytes {
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0, UInt32(temporary.utf8.count), flags, 0o600,
|
||||
sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
UInt32(temporaryBytes.count), flags, 0o600,
|
||||
Int32(LIBSSH2_SFTP_OPENFILE))
|
||||
}
|
||||
if let handle {
|
||||
@@ -1014,6 +1047,17 @@ actor SFTPChannel: SFTPChannelProtocol {
|
||||
return .operation(message)
|
||||
}
|
||||
|
||||
private func encodePath(_ path: String) throws -> Data {
|
||||
guard !path.utf8.contains(0) else {
|
||||
throw SFTPError.operation(String(localized: "A remote path contains an invalid null character."))
|
||||
}
|
||||
return try RemoteCharsetStreamConverter.encode(path, charset: charset)
|
||||
}
|
||||
|
||||
private nonisolated static func decodePath(_ data: Data, charset: RemoteCharset) throws -> String {
|
||||
try RemoteCharsetStreamConverter.decode(data, charset: charset)
|
||||
}
|
||||
|
||||
private static func remoteFile(
|
||||
path: String, name: String? = nil, attributes: LIBSSH2_SFTP_ATTRIBUTES
|
||||
) -> RemoteFile {
|
||||
|
||||
@@ -2,6 +2,7 @@ import AgentKit
|
||||
import Foundation
|
||||
import Logging
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
public nonisolated struct SSHExecResult: Hashable, Sendable {
|
||||
public var stdout: String
|
||||
@@ -25,19 +26,34 @@ public nonisolated struct SSHExecResult: Hashable, Sendable {
|
||||
}
|
||||
|
||||
public nonisolated enum SSHCommandInput: Sendable {
|
||||
/// Byte-transparent input. Charset conversion is intentionally bypassed.
|
||||
case plain(Data)
|
||||
/// Text encoded strictly with the connected Host's Charset.
|
||||
case text(String)
|
||||
/// UTF-8 secret text converted without materializing a Swift String.
|
||||
case secret(AgentSecret)
|
||||
|
||||
func append(to queue: SSHByteQueue) throws {
|
||||
func append(to queue: SSHByteQueue, charset: RemoteCharset = .utf8) throws {
|
||||
switch self {
|
||||
case .plain(var bytes):
|
||||
if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) }
|
||||
queue.append(bytes)
|
||||
case .text(let value):
|
||||
var bytes = try RemoteCharsetStreamConverter.encode(value, charset: charset)
|
||||
defer { bytes.resetBytes(in: bytes.indices) }
|
||||
if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) }
|
||||
queue.append(bytes)
|
||||
case .secret(let secret):
|
||||
defer { secret.clear() }
|
||||
try secret.withUnsafeBytes { bytes in
|
||||
queue.append(bytes)
|
||||
if !bytes.isEmpty, bytes.last != 0x0A {
|
||||
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding)
|
||||
var source = Data(bytes)
|
||||
defer { source.resetBytes(in: source.indices) }
|
||||
var converted = try converter.convert(source).data
|
||||
defer { converted.resetBytes(in: converted.indices) }
|
||||
converted.append(try converter.finish().data)
|
||||
queue.append(converted)
|
||||
if !converted.isEmpty, converted.last != 0x0A {
|
||||
queue.append(contentsOf: [0x0A])
|
||||
}
|
||||
}
|
||||
@@ -80,7 +96,7 @@ nonisolated extension SSHConnectionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on the connection's serial libssh2 owner. It must return promptly.
|
||||
/// Called on the connection's serial libssh2 owner with valid UTF-8 output. It must return promptly.
|
||||
public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void
|
||||
|
||||
public actor SSHConnection: SSHConnectionProtocol {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Darwin
|
||||
import Foundation
|
||||
import Logging
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
/// One authenticated SSH connection with any number of non-PTY exec Channels.
|
||||
///
|
||||
@@ -15,8 +16,8 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
let pointer: UnsafeMutablePointer<CChar>
|
||||
let count: Int
|
||||
|
||||
init(_ value: String) {
|
||||
let bytes = Array(value.utf8)
|
||||
init(_ data: Data) {
|
||||
let bytes = Array(data)
|
||||
count = bytes.count
|
||||
pointer = .allocate(capacity: count + 1)
|
||||
for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) }
|
||||
@@ -43,6 +44,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
var sentEOF = false
|
||||
var stdout = Data()
|
||||
var stderr = Data()
|
||||
var stdoutRemoteBytes = 0
|
||||
var stderrRemoteBytes = 0
|
||||
let stdoutDecoder: RemoteCharsetStreamConverter
|
||||
let stderrDecoder: RemoteCharsetStreamConverter
|
||||
let charset: RemoteCharset
|
||||
var stdoutTruncated = false
|
||||
var stderrTruncated = false
|
||||
var terminalError: (any Error)?
|
||||
@@ -52,7 +58,8 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
var completed = false
|
||||
|
||||
init(
|
||||
command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval,
|
||||
command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset,
|
||||
timeout: TimeInterval,
|
||||
outputLimit: Int, cancellation: TransferCancellationToken,
|
||||
onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease,
|
||||
continuation: CheckedContinuation<SSHExecResult, any Error>
|
||||
@@ -63,14 +70,18 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
} else {
|
||||
effective = "/bin/sh -lc \(Self.shellQuote(command))"
|
||||
}
|
||||
self.command = StableCString(effective)
|
||||
self.command = StableCString(
|
||||
try RemoteCharsetStreamConverter.encode(effective, charset: charset))
|
||||
self.charset = charset
|
||||
stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
|
||||
stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
|
||||
self.cancellation = cancellation
|
||||
deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60))))
|
||||
self.outputLimit = outputLimit
|
||||
self.onOutput = onOutput
|
||||
self.channelLease = channelLease
|
||||
self.continuation = continuation
|
||||
try stdin?.append(to: input)
|
||||
try stdin?.append(to: input, charset: charset)
|
||||
sentEOF = input.isEmpty
|
||||
}
|
||||
|
||||
@@ -88,6 +99,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
private var socketEvents: SSHSocketEvents?
|
||||
private var timer: (any DispatchSourceTimer)?
|
||||
private var hostID: UUID?
|
||||
private var charset: RemoteCharset = .utf8
|
||||
private var commands: [UUID: SSHExecChannel] = [:]
|
||||
private var pendingOpens = SSHReadyQueue<UUID>()
|
||||
private var opening: SSHExecChannel?
|
||||
@@ -114,7 +126,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
else { return nil }
|
||||
return .init(
|
||||
connection: connection, buffer: buffer,
|
||||
channelBudget: channelBudget)
|
||||
channelBudget: channelBudget, charset: self.charset)
|
||||
},
|
||||
updateEvents: { [weak self] session, buffer in
|
||||
self?.updateSharedConnectionEvents(session: session, buffer: buffer)
|
||||
@@ -147,6 +159,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
try Task.checkCancellation()
|
||||
self.connection = opened
|
||||
self.hostID = hostID
|
||||
self.charset = request.configuration.charset
|
||||
self.buffer = opened.installBuffer()
|
||||
self.channelBudget = channelBudget
|
||||
self.connectionFailure = connectionFailure
|
||||
@@ -207,7 +220,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
let context: SSHExecChannel
|
||||
do {
|
||||
context = try SSHExecChannel(
|
||||
command: command, workingDirectory: workingDirectory, stdin: stdin,
|
||||
command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset,
|
||||
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation,
|
||||
onOutput: onOutput, channelLease: channelLease, continuation: continuation)
|
||||
} catch {
|
||||
@@ -443,11 +456,19 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
if let error = command.terminalError {
|
||||
finish(command, throwing: error)
|
||||
} else {
|
||||
guard let stdout = String(data: command.stdout, encoding: .utf8),
|
||||
let stderr = String(data: command.stderr, encoding: .utf8)
|
||||
else {
|
||||
finish(
|
||||
command,
|
||||
throwing: RemoteCharsetConversionError(
|
||||
charset: command.charset, direction: .decoding))
|
||||
return true
|
||||
}
|
||||
finish(
|
||||
command,
|
||||
returning: SSHExecResult(
|
||||
stdout: String(decoding: command.stdout, as: UTF8.self),
|
||||
stderr: String(decoding: command.stderr, as: UTF8.self),
|
||||
stdout: stdout, stderr: stderr,
|
||||
exitCode: command.exitCode, signal: command.signal,
|
||||
stdoutTruncated: command.stdoutTruncated,
|
||||
stderrTruncated: command.stderrTruncated))
|
||||
@@ -495,15 +516,13 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
progressed = true
|
||||
remaining -= count
|
||||
let chunk = Data(bytes: scratch, count: count)
|
||||
command.onOutput?(chunk, stream != 0)
|
||||
if stream == 0 {
|
||||
Self.appendCapped(
|
||||
chunk, to: &command.stdout, limit: command.outputLimit,
|
||||
truncated: &command.stdoutTruncated)
|
||||
} else {
|
||||
Self.appendCapped(
|
||||
chunk, to: &command.stderr, limit: command.outputLimit,
|
||||
truncated: &command.stderrTruncated)
|
||||
do {
|
||||
try appendDecoded(chunk, stream: stream, to: command)
|
||||
} catch {
|
||||
command.terminalError = error
|
||||
command.phase = .closing
|
||||
ready.insert(command.id)
|
||||
return true
|
||||
}
|
||||
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
|
||||
if count == LIBSSH2_ERROR_EAGAIN { blocked(session) }
|
||||
@@ -514,6 +533,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
if libssh2_channel_eof(execChannel) == 1 {
|
||||
do {
|
||||
try finishDecodedOutput(command)
|
||||
} catch {
|
||||
command.terminalError = error
|
||||
}
|
||||
command.phase = .closing
|
||||
ready.insert(command.id)
|
||||
return true
|
||||
@@ -524,6 +548,42 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
return progressed
|
||||
}
|
||||
|
||||
private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws {
|
||||
let isError = stream != 0
|
||||
let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes
|
||||
let available = max(0, command.outputLimit - consumed)
|
||||
let accepted = Data(chunk.prefix(available))
|
||||
if isError {
|
||||
command.stderrRemoteBytes += accepted.count
|
||||
if accepted.count < chunk.count { command.stderrTruncated = true }
|
||||
} else {
|
||||
command.stdoutRemoteBytes += accepted.count
|
||||
if accepted.count < chunk.count { command.stdoutTruncated = true }
|
||||
}
|
||||
guard !accepted.isEmpty else { return }
|
||||
let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data
|
||||
guard !decoded.isEmpty else { return }
|
||||
command.onOutput?(decoded, isError)
|
||||
if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) }
|
||||
}
|
||||
|
||||
private func finishDecodedOutput(_ command: SSHExecChannel) throws {
|
||||
if !command.stdoutTruncated {
|
||||
let tail = try command.stdoutDecoder.finish().data
|
||||
if !tail.isEmpty {
|
||||
command.onOutput?(tail, false)
|
||||
command.stdout.append(tail)
|
||||
}
|
||||
}
|
||||
if !command.stderrTruncated {
|
||||
let tail = try command.stderrDecoder.finish().data
|
||||
if !tail.isEmpty {
|
||||
command.onOutput?(tail, true)
|
||||
command.stderr.append(tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool {
|
||||
let code = libssh2_session_last_errno(session)
|
||||
let error = SSHConnectionOpener.connectionError(session, prefix: prefix)
|
||||
@@ -618,11 +678,4 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendCapped(
|
||||
_ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool
|
||||
) {
|
||||
let remaining = max(0, limit - destination.count)
|
||||
if chunk.count > remaining { truncated = true }
|
||||
if remaining > 0 { destination.append(chunk.prefix(remaining)) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import CSSH2
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
/// Owns every SFTP subsystem channel attached to one non-blocking SSH connection.
|
||||
///
|
||||
@@ -11,6 +12,7 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable {
|
||||
var connection: SSHAuthenticatedConnection
|
||||
var buffer: SSHConnectionBuffer
|
||||
var channelBudget: SSHChannelBudget
|
||||
var charset: RemoteCharset
|
||||
}
|
||||
|
||||
private struct OwnedChannel {
|
||||
@@ -54,7 +56,11 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable {
|
||||
guard let self else { throw SFTPError.notConnected }
|
||||
return try await self.perform(id, operation)
|
||||
})
|
||||
return try await SFTPChannel.open(using: driver)
|
||||
let charset = try await queue.perform { [self] in
|
||||
guard let context = context() else { throw SFTPError.notConnected }
|
||||
return context.charset
|
||||
}
|
||||
return try await SFTPChannel.open(using: driver, charset: charset)
|
||||
}
|
||||
|
||||
/// Called by the enclosing SSH owner while already executing on `queue`.
|
||||
|
||||
@@ -38,6 +38,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
public var homeDirectory: String?
|
||||
public var homeResolutionUnavailable = false
|
||||
var diagnostic: Diagnostic?
|
||||
var rejectedCharsetInput = false
|
||||
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
|
||||
public let token: String
|
||||
public let startCommand: Data
|
||||
private let charset: RemoteCharset
|
||||
private var deadline: UInt64
|
||||
private var phase: Phase = .probing
|
||||
private var buffer = Data()
|
||||
@@ -63,6 +65,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
private var installationStartedLate = false
|
||||
private var detectedShell: String?
|
||||
private var detectedSystemInfo: RemoteSystemInfo?
|
||||
private var rejectedCharsetInput = false
|
||||
|
||||
private var probeEchoMarker: Data {
|
||||
Data("__TRMD_\(token)_PROBE__".utf8)
|
||||
@@ -70,9 +73,11 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
|
||||
public init(
|
||||
token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""),
|
||||
charset: RemoteCharset = .utf8,
|
||||
now: UInt64 = DispatchTime.now().uptimeNanoseconds
|
||||
) {
|
||||
self.token = token
|
||||
self.charset = charset
|
||||
// The POSIX helper reads os-release as inert assignment text instead of
|
||||
// sourcing it. Its output precedes the shell marker, so a missing `sh`
|
||||
// still leaves the original shell-only bootstrap path usable.
|
||||
@@ -192,9 +197,14 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
)
|
||||
)
|
||||
}
|
||||
let shellPath = String(
|
||||
decoding: buffer[shellStart..<terminatorRange.lowerBound], as: UTF8.self
|
||||
)
|
||||
let shellBytes = Data(buffer[shellStart..<terminatorRange.lowerBound])
|
||||
let shellPath: String
|
||||
if let decoded = try? RemoteCharsetStreamConverter.decode(shellBytes, charset: charset) {
|
||||
shellPath = decoded
|
||||
} else {
|
||||
rejectedCharsetInput = true
|
||||
shellPath = ""
|
||||
}
|
||||
let shell = shellPath.split(separator: "/").last.map(String.init)?.lowercased()
|
||||
detectedShell = shell
|
||||
detectedSystemInfo = parseSystemInfo(in: buffer)
|
||||
@@ -208,7 +218,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
return Update(
|
||||
visibleOutput: visible, isComplete: true, shell: detectedShell,
|
||||
systemInfo: detectedSystemInfo,
|
||||
diagnostic: .skipped(.unsupportedShell)
|
||||
diagnostic: .skipped(.unsupportedShell),
|
||||
rejectedCharsetInput: rejectedCharsetInput
|
||||
)
|
||||
}
|
||||
guard !(isLate && userDidWrite) else {
|
||||
@@ -218,7 +229,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
return Update(
|
||||
visibleOutput: visible, isComplete: true, shell: detectedShell,
|
||||
systemInfo: detectedSystemInfo,
|
||||
diagnostic: .skipped(.userInput)
|
||||
diagnostic: .skipped(.userInput),
|
||||
rejectedCharsetInput: rejectedCharsetInput
|
||||
)
|
||||
}
|
||||
phase = .installing
|
||||
@@ -246,18 +258,23 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
diagnostic: .installed(
|
||||
late: installationStartedLate, homeResolved: homeDirectory != nil,
|
||||
systemInfoAvailable: detectedSystemInfo != nil
|
||||
)
|
||||
),
|
||||
rejectedCharsetInput: rejectedCharsetInput
|
||||
)
|
||||
}
|
||||
|
||||
private func parseHomeDirectory(in data: Data) -> String? {
|
||||
private mutating func parseHomeDirectory(in data: Data) -> String? {
|
||||
let prefix = Data("__TRMD_\(token)_HOME__".utf8)
|
||||
guard let prefixRange = data.range(of: prefix),
|
||||
let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A)
|
||||
else { return nil }
|
||||
var payload = Data(data[prefixRange.upperBound..<lineEnd])
|
||||
if payload.last == 0x0D { payload.removeLast() }
|
||||
guard let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload),
|
||||
guard let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset) else {
|
||||
rejectedCharsetInput = true
|
||||
return nil
|
||||
}
|
||||
guard
|
||||
decoded.hasPrefix("/"),
|
||||
!decoded.unicodeScalars.contains(where: {
|
||||
CharacterSet.controlCharacters.contains($0)
|
||||
@@ -267,7 +284,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func parseSystemInfo(in data: Data) -> RemoteSystemInfo? {
|
||||
private mutating func parseSystemInfo(in data: Data) -> RemoteSystemInfo? {
|
||||
let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8)
|
||||
guard let prefixRange = data.range(of: prefix),
|
||||
let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A)
|
||||
@@ -278,7 +295,11 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
guard payload.count >= 2, payload.suffix(2).elementsEqual(Data("__".utf8))
|
||||
else { return nil }
|
||||
payload.removeLast(2)
|
||||
return RemoteSystemInfo(probeFields: String(decoding: payload, as: UTF8.self))
|
||||
guard let fields = try? RemoteCharsetStreamConverter.decode(payload, charset: charset) else {
|
||||
rejectedCharsetInput = true
|
||||
return nil
|
||||
}
|
||||
return RemoteSystemInfo(probeFields: fields)
|
||||
}
|
||||
|
||||
private func removingSystemInfoMarker(from data: Data) -> Data {
|
||||
@@ -458,6 +479,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
public nonisolated struct Update: Sendable {
|
||||
public var visibleOutput = Data()
|
||||
public var events: [Event] = []
|
||||
public var rejectedCharsetInput = false
|
||||
|
||||
}
|
||||
|
||||
@@ -465,13 +487,15 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
private let commandFinishedPrefix: Data
|
||||
private let promptPrefix: Data
|
||||
private let draftPrefix: Data
|
||||
private let charset: RemoteCharset
|
||||
private var buffer = Data()
|
||||
|
||||
public init(token: String) {
|
||||
public init(token: String, charset: RemoteCharset = .utf8) {
|
||||
commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8)
|
||||
commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8)
|
||||
promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8)
|
||||
draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8)
|
||||
self.charset = charset
|
||||
}
|
||||
|
||||
public mutating func receive(_ data: Data) -> Update {
|
||||
@@ -502,8 +526,10 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
}
|
||||
switch match.kind {
|
||||
case .command:
|
||||
if let command = Self.decodePercentEncoded(payload), !command.isEmpty {
|
||||
if let command = Self.decodePercentEncoded(payload, charset: charset), !command.isEmpty {
|
||||
update.events.append(.commandStarted(command))
|
||||
} else {
|
||||
update.rejectedCharsetInput = true
|
||||
}
|
||||
case .commandFinished:
|
||||
let fields = String(decoding: payload, as: UTF8.self)
|
||||
@@ -521,8 +547,10 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
guard payload.isEmpty else { break }
|
||||
update.events.append(.promptReady)
|
||||
case .draft:
|
||||
if let draft = Self.decodePercentEncoded(payload) {
|
||||
if let draft = Self.decodePercentEncoded(payload, charset: charset) {
|
||||
update.events.append(.draft(draft))
|
||||
} else {
|
||||
update.rejectedCharsetInput = true
|
||||
}
|
||||
}
|
||||
buffer = Data(buffer[buffer.index(after: end)...])
|
||||
@@ -562,7 +590,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
return candidates.min { $0.0.lowerBound < $1.0.lowerBound }
|
||||
}
|
||||
|
||||
public static func decodePercentEncoded(_ data: Data) -> String? {
|
||||
public static func decodePercentEncoded(_ data: Data, charset: RemoteCharset = .utf8) -> String? {
|
||||
let bytes = Array(data)
|
||||
var decoded: [UInt8] = []
|
||||
decoded.reserveCapacity(bytes.count)
|
||||
@@ -578,7 +606,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return String(data: Data(decoded), encoding: .utf8)
|
||||
return try? RemoteCharsetStreamConverter.decode(Data(decoded), charset: charset)
|
||||
}
|
||||
|
||||
private static func hex(_ byte: UInt8) -> UInt8? {
|
||||
@@ -603,6 +631,7 @@ public actor SSHPTYChannel {
|
||||
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
|
||||
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
|
||||
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
|
||||
public typealias CharsetErrorHandler = @Sendable (String) -> Void
|
||||
|
||||
private nonisolated let worker: SSHPTYChannelWorker
|
||||
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
|
||||
@@ -708,6 +737,10 @@ public actor SSHPTYChannel {
|
||||
environmentRejection = rejection
|
||||
}
|
||||
|
||||
public func configureCharsetErrors(_ handler: @escaping CharsetErrorHandler) {
|
||||
worker.setCharsetErrorHandler(handler)
|
||||
}
|
||||
|
||||
public func write(_ data: Data) {
|
||||
worker.write(data)
|
||||
}
|
||||
@@ -730,6 +763,8 @@ public actor SSHPTYChannel {
|
||||
}
|
||||
|
||||
nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private static let bracketedPasteStart = Data("\u{1B}[200~".utf8)
|
||||
private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8)
|
||||
private static let initialization: Void = {
|
||||
precondition(libssh2_init(0) == 0, "libssh2 initialization failed")
|
||||
}()
|
||||
@@ -744,6 +779,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var connection: SSHAuthenticatedConnection?
|
||||
private var ptyChannel: OpaquePointer?
|
||||
private var hostID: Host.ID?
|
||||
private var charset: RemoteCharset = .utf8
|
||||
private var pumpTimer: (any DispatchSourceTimer)?
|
||||
private var buffer: SSHConnectionBuffer?
|
||||
private var channelBudget: SSHChannelBudget?
|
||||
@@ -754,6 +790,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var shellIntegration: RemoteShellIntegrationBootstrap?
|
||||
private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger
|
||||
private var commandEventFilter: RemoteCommandEventFilter?
|
||||
private var inputConverter: RemoteCharsetStreamConverter?
|
||||
private var outputConverter: RemoteCharsetStreamConverter?
|
||||
private var workingDirectoryFilter: RemoteOSC7CharsetFilter?
|
||||
private var charsetErrorHandler: SSHPTYChannel.CharsetErrorHandler = { _ in }
|
||||
private var didReportExit = false
|
||||
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
|
||||
|
||||
@@ -780,7 +820,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
else { return nil }
|
||||
return .init(
|
||||
connection: connection, buffer: buffer,
|
||||
channelBudget: channelBudget)
|
||||
channelBudget: channelBudget, charset: self.charset)
|
||||
},
|
||||
updateEvents: { [weak self] session, buffer in
|
||||
self?.updateSharedConnectionEvents(session: session, buffer: buffer)
|
||||
@@ -1039,6 +1079,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
ptyResize = nextResize
|
||||
return generation
|
||||
}
|
||||
let encodedEnvironment = try environmentVariables.map { variable in
|
||||
(
|
||||
variable.variable,
|
||||
try RemoteCharsetStreamConverter.encode(variable.variable, charset: request.configuration.charset),
|
||||
try RemoteCharsetStreamConverter.encode(variable.value, charset: request.configuration.charset)
|
||||
)
|
||||
}
|
||||
do {
|
||||
let channelBudget = maxSessionsPolicy.makeBudget()
|
||||
let ptyChannelLease = try await channelBudget.acquire()
|
||||
@@ -1054,6 +1101,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
connection = openedConnection
|
||||
hostID = request.configuration.hostID
|
||||
charset = request.configuration.charset
|
||||
inputConverter = try RemoteCharsetStreamConverter(
|
||||
charset: request.configuration.charset, direction: .encoding)
|
||||
outputConverter = try RemoteCharsetStreamConverter(
|
||||
charset: request.configuration.charset, direction: .decoding)
|
||||
workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: request.configuration.charset)
|
||||
self.channelBudget = channelBudget
|
||||
self.ptyChannelLease = ptyChannelLease
|
||||
let created = openedConnection.libssh2Session
|
||||
@@ -1075,16 +1128,19 @@ 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
|
||||
for environmentVariable in encodedEnvironment {
|
||||
let result = environmentVariable.1.withUnsafeBytes { variable in
|
||||
environmentVariable.2.withUnsafeBytes { value in
|
||||
libssh2_channel_setenv_ex(
|
||||
openedChannel, variable, UInt32(environmentVariable.variable.utf8.count),
|
||||
value, UInt32(environmentVariable.value.utf8.count))
|
||||
openedChannel,
|
||||
variable.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
UInt32(environmentVariable.1.count),
|
||||
value.baseAddress?.assumingMemoryBound(to: CChar.self),
|
||||
UInt32(environmentVariable.2.count))
|
||||
}
|
||||
}
|
||||
if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED {
|
||||
rejectedVariables.append(environmentVariable.variable)
|
||||
rejectedVariables.append(environmentVariable.0)
|
||||
} else if result != 0 {
|
||||
throw Self.connectionError(created, prefix: "SSH environment request failed")
|
||||
}
|
||||
@@ -1102,9 +1158,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
enabled: shellIntegrationEnabled
|
||||
)
|
||||
if shellIntegrationEnabled {
|
||||
let bootstrap = RemoteShellIntegrationBootstrap()
|
||||
let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset)
|
||||
pendingWrites.append(bootstrap.startCommand)
|
||||
commandEventFilter = RemoteCommandEventFilter(token: bootstrap.token)
|
||||
commandEventFilter = RemoteCommandEventFilter(
|
||||
token: bootstrap.token, charset: request.configuration.charset)
|
||||
shellIntegration = bootstrap
|
||||
}
|
||||
startPump()
|
||||
@@ -1127,11 +1184,26 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
integration.noteUserInput()
|
||||
shellIntegration = integration
|
||||
}
|
||||
pendingWrites.append(data)
|
||||
do {
|
||||
let converted = try inputConverter?.convert(data).data ?? data
|
||||
if !converted.isEmpty { pendingWrites.append(converted) }
|
||||
} catch {
|
||||
if data.range(of: Self.bracketedPasteStart) != nil {
|
||||
pendingWrites.append(Self.bracketedPasteStart)
|
||||
}
|
||||
if data.range(of: Self.bracketedPasteEnd) != nil {
|
||||
pendingWrites.append(Self.bracketedPasteEnd)
|
||||
}
|
||||
charsetErrorHandler(error.localizedDescription)
|
||||
}
|
||||
pump()
|
||||
}
|
||||
}
|
||||
|
||||
func setCharsetErrorHandler(_ handler: @escaping SSHPTYChannel.CharsetErrorHandler) {
|
||||
queue.async { self.charsetErrorHandler = handler }
|
||||
}
|
||||
|
||||
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
|
||||
guard columns > 0, rows > 0 else { return }
|
||||
queue.async { [self] in
|
||||
@@ -1471,6 +1543,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private func applyShellIntegrationUpdate(
|
||||
_ update: RemoteShellIntegrationBootstrap.Update
|
||||
) {
|
||||
if update.rejectedCharsetInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter?.charset ?? .utf8, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
if let diagnostic = update.diagnostic, let hostID {
|
||||
shellIntegrationLifecycleLogger.record(diagnostic, hostID: hostID)
|
||||
}
|
||||
@@ -1499,12 +1578,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
|
||||
private func deliverIntegratedOutput(_ data: Data) {
|
||||
guard var commandEventFilter else {
|
||||
output(data)
|
||||
emitConvertedOutput(data)
|
||||
return
|
||||
}
|
||||
let update = commandEventFilter.receive(data)
|
||||
self.commandEventFilter = commandEventFilter
|
||||
if !update.visibleOutput.isEmpty { output(update.visibleOutput) }
|
||||
if !update.visibleOutput.isEmpty { emitConvertedOutput(update.visibleOutput) }
|
||||
if update.rejectedCharsetInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter?.charset ?? .utf8, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
for event in update.events {
|
||||
switch event {
|
||||
case .commandStarted(let command):
|
||||
@@ -1519,12 +1605,79 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func emitConvertedOutput(_ data: Data) {
|
||||
do {
|
||||
var filteredData = data
|
||||
if var filter = workingDirectoryFilter {
|
||||
let filtered = filter.receive(data)
|
||||
workingDirectoryFilter = filter
|
||||
filteredData = filtered.data
|
||||
if filtered.rejectedInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter?.charset ?? .utf8, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
let converted =
|
||||
try outputConverter?.convert(filteredData, recoveringInvalidInput: true)
|
||||
?? .init(data: filteredData, rejectedInput: false)
|
||||
if !converted.data.isEmpty { output(converted.data) }
|
||||
if converted.rejectedInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter?.charset ?? .utf8, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
charsetErrorHandler(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishCharsetOutput() {
|
||||
guard let outputConverter else { return }
|
||||
do {
|
||||
if var filter = workingDirectoryFilter {
|
||||
let remainder = filter.finish()
|
||||
workingDirectoryFilter = filter
|
||||
if !remainder.isEmpty {
|
||||
let prefix = try outputConverter.convert(remainder, recoveringInvalidInput: true)
|
||||
if !prefix.data.isEmpty { output(prefix.data) }
|
||||
if prefix.rejectedInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter.charset, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
let converted = try outputConverter.finish(recoveringInvalidInput: true)
|
||||
if !converted.data.isEmpty { output(converted.data) }
|
||||
if converted.rejectedInput {
|
||||
charsetErrorHandler(
|
||||
RemoteCharsetConversionError(
|
||||
charset: outputConverter.charset, direction: .decoding
|
||||
).localizedDescription
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
charsetErrorHandler(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeTerminalSession() {
|
||||
finishCharsetOutput()
|
||||
pendingWrites.removeAll()
|
||||
ptyResize.reset()
|
||||
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
|
||||
shellIntegration = nil
|
||||
commandEventFilter = nil
|
||||
inputConverter = nil
|
||||
outputConverter = nil
|
||||
workingDirectoryFilter = nil
|
||||
hostID = nil
|
||||
if let ptyChannel {
|
||||
_ = libssh2_channel_send_eof(ptyChannel)
|
||||
@@ -1547,6 +1700,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
|
||||
shellIntegration = nil
|
||||
commandEventFilter = nil
|
||||
inputConverter = nil
|
||||
outputConverter = nil
|
||||
workingDirectoryFilter = nil
|
||||
hostID = nil
|
||||
if let ptyChannel {
|
||||
_ = libssh2_channel_send_eof(ptyChannel)
|
||||
|
||||
@@ -194,6 +194,10 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
|
||||
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.
|
||||
- Host Charset is Vault-synced and snapshotted by each connection attempt. It strictly covers PTY text, exec
|
||||
commands/output and SFTP path names; file contents and raw command input remain byte-transparent. Invalid PTY or
|
||||
Serial stream sequences are discarded with one session-visible error, while exec and SFTP operations fail before
|
||||
returning replacement text or mutating a different path. Serial Charset is temporary session configuration.
|
||||
- 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.
|
||||
|
||||
@@ -158,6 +158,7 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
}
|
||||
HostSessionSection(
|
||||
charset: $draft.charset,
|
||||
sftpInitialPath: $draft.sftpChannelInitialPath,
|
||||
startupSnippetID: startupSnippetChoice,
|
||||
environmentVariables: $draft.environmentVariables,
|
||||
|
||||
@@ -590,6 +590,7 @@ final class MobileWorkspace: Identifiable {
|
||||
generation = token
|
||||
activeHistoryID = nil
|
||||
resetCommandGeneratorShellState()
|
||||
operationError = nil
|
||||
busy = true
|
||||
connectionFailure = nil
|
||||
status = String(localized: reconnecting ? "Reconnecting" : "Connecting")
|
||||
@@ -697,6 +698,12 @@ final class MobileWorkspace: Identifiable {
|
||||
handlers.exit,
|
||||
handlers.shellIntegration)
|
||||
ssh = session
|
||||
await session.configureCharsetErrors { [weak self] message in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == token, self.operationError == nil else { return }
|
||||
self.operationError = message
|
||||
}
|
||||
}
|
||||
await session.configureEnvironment(host.environmentVariables) { variables in
|
||||
let list = variables.joined(separator: ", ")
|
||||
let warning = String(localized: "The server rejected these environment variables: \(list)")
|
||||
|
||||
@@ -364,7 +364,12 @@ struct HostChooserView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Terminal Input") {
|
||||
Section("Terminal") {
|
||||
Picker("Charset", selection: $serialConfiguration.charset) {
|
||||
ForEach(RemoteCharset.allCases) { charset in
|
||||
Text(charset.displayName).tag(charset)
|
||||
}
|
||||
}
|
||||
Picker("Enter Sends", selection: $serialConfiguration.enterKey) {
|
||||
ForEach(SerialEnterKey.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ struct HostEditorView: View {
|
||||
}
|
||||
|
||||
HostSessionSection(
|
||||
charset: $draft.charset,
|
||||
sftpInitialPath: $draft.sftpChannelInitialPath,
|
||||
startupSnippetID: startupSnippetChoice,
|
||||
environmentVariables: $draft.environmentVariables,
|
||||
|
||||
@@ -393,6 +393,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: .allowChallenge
|
||||
),
|
||||
charset: host.charset,
|
||||
authenticationReference: SSHAuthenticationReference(
|
||||
identityID: suppliedAuthentication.identityID,
|
||||
fallbackKeyID: suppliedAuthentication.identityID == nil
|
||||
|
||||
@@ -145,6 +145,7 @@ final class SSHSessionModel: Identifiable {
|
||||
RemotePath.displayed(path, home: homeResolution)
|
||||
}
|
||||
var hasUnreadOutput: Bool = false
|
||||
var charsetError: String?
|
||||
/// History for an unsaved Quick Connect destination. Saved Hosts use the
|
||||
/// app-wide CommandHistoryModel instead so every window sees the same list.
|
||||
private(set) var transientCommandHistory: [CommandHistoryEntry] = []
|
||||
@@ -460,6 +461,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
private func startConnection(using store: any RemoteSessionHostStore) {
|
||||
resetCommandGeneratorShellState()
|
||||
charsetError = nil
|
||||
remoteWindowTitle = nil
|
||||
homeResolution = .unavailable
|
||||
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
|
||||
@@ -912,6 +914,7 @@ final class SSHSessionModel: Identifiable {
|
||||
hostID: host.id, host: host.host, port: host.port, username: username,
|
||||
credentials: credentials, expectedHostKeys: expectedHostKeys,
|
||||
proxy: proxy, jumps: jumps,
|
||||
charset: host.charset,
|
||||
authenticationReference: authenticationReference
|
||||
)
|
||||
let backend = terminalBackend
|
||||
@@ -930,6 +933,12 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
)
|
||||
await actor.configureCharsetErrors { [weak self] message in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionAttemptID == attemptID, self.charsetError == nil else { return }
|
||||
self.charsetError = message
|
||||
}
|
||||
}
|
||||
ssh = actor
|
||||
do {
|
||||
await actor.configureEnvironment(host.environmentVariables) { variables in
|
||||
|
||||
@@ -3,11 +3,17 @@ import GhosttyTerminal
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
@MainActor
|
||||
private final class SerialSessionRelay {
|
||||
weak var owner: SerialSessionModel?
|
||||
let inputCapture = TerminalInteractiveInputCapture()
|
||||
let decoder: RemoteCharsetStreamConverter
|
||||
|
||||
init(charset: RemoteCharset) {
|
||||
decoder = try! RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private final class WeakSerialSessionReference: @unchecked Sendable {
|
||||
@@ -23,6 +29,7 @@ final class SerialSessionModel: Identifiable {
|
||||
private(set) var configuration: SerialConnectionConfiguration
|
||||
var status: ConnectionStatus = .disconnected
|
||||
var hasUnreadOutput = false
|
||||
var charsetError: String?
|
||||
@ObservationIgnored lazy var find = TerminalFindController(
|
||||
perform: { [weak self] in self?.performFind($0) },
|
||||
focus: { [weak self] in self?.requestTerminalFocus() }
|
||||
@@ -34,6 +41,9 @@ final class SerialSessionModel: Identifiable {
|
||||
#endif
|
||||
private let terminalBackend: InMemoryTerminalSession
|
||||
private let service: any SerialPortServing
|
||||
private let relay: SerialSessionRelay
|
||||
private let inputConverter: RemoteCharsetStreamConverter
|
||||
private let outputConverter: RemoteCharsetStreamConverter
|
||||
private var connection: (any SerialPortConnection)?
|
||||
private var connectionTask: Task<Void, Never>?
|
||||
private var startsOnMount = true
|
||||
@@ -50,7 +60,10 @@ final class SerialSessionModel: Identifiable {
|
||||
init(configuration: SerialConnectionConfiguration, service: any SerialPortServing) {
|
||||
self.configuration = configuration
|
||||
self.service = service
|
||||
let relay = SerialSessionRelay()
|
||||
let relay = SerialSessionRelay(charset: configuration.charset)
|
||||
self.relay = relay
|
||||
inputConverter = try! RemoteCharsetStreamConverter(charset: configuration.charset, direction: .encoding)
|
||||
outputConverter = relay.decoder
|
||||
let backend = InMemoryTerminalSession(
|
||||
write: { data in
|
||||
let interactive = relay.inputCapture.classifyWrite(data)
|
||||
@@ -89,6 +102,7 @@ final class SerialSessionModel: Identifiable {
|
||||
|
||||
func connect() {
|
||||
startsOnMount = false
|
||||
charsetError = nil
|
||||
connectionTask?.cancel()
|
||||
let previousConnection = connection
|
||||
connection = nil
|
||||
@@ -97,6 +111,7 @@ final class SerialSessionModel: Identifiable {
|
||||
let service = service
|
||||
let configuration = configuration
|
||||
let backend = terminalBackend
|
||||
let decoder = outputConverter
|
||||
let reference = WeakSerialSessionReference(self)
|
||||
connectionTask = Task { [weak self] in
|
||||
do {
|
||||
@@ -104,7 +119,26 @@ final class SerialSessionModel: Identifiable {
|
||||
let opened = try await service.open(
|
||||
configuration,
|
||||
receive: { data in
|
||||
backend.receive(data)
|
||||
do {
|
||||
let converted = try decoder.convert(data, recoveringInvalidInput: true)
|
||||
if !converted.data.isEmpty { backend.receive(converted.data) }
|
||||
if converted.rejectedInput {
|
||||
let message = RemoteCharsetConversionError(
|
||||
charset: configuration.charset, direction: .decoding
|
||||
).localizedDescription
|
||||
Task { @MainActor in
|
||||
if reference.owner?.charsetError == nil {
|
||||
reference.owner?.charsetError = message
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Task { @MainActor in
|
||||
if reference.owner?.charsetError == nil {
|
||||
reference.owner?.charsetError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
Task { @MainActor in reference.owner?.hasUnreadOutput = true }
|
||||
},
|
||||
disconnected: { message in
|
||||
@@ -141,6 +175,7 @@ final class SerialSessionModel: Identifiable {
|
||||
connectionTask = nil
|
||||
let connection = connection
|
||||
self.connection = nil
|
||||
finishCharsetOutput()
|
||||
if let connection { Task { await connection.close() } }
|
||||
status = .disconnected
|
||||
connectionController.disconnect()
|
||||
@@ -150,6 +185,7 @@ final class SerialSessionModel: Identifiable {
|
||||
guard status != .disconnected else { return }
|
||||
let connection = connection
|
||||
self.connection = nil
|
||||
finishCharsetOutput()
|
||||
if let connection { Task { await connection.close() } }
|
||||
status = .failed(message)
|
||||
connectionJourney.fail(message)
|
||||
@@ -160,11 +196,15 @@ final class SerialSessionModel: Identifiable {
|
||||
|
||||
func handleTerminalWrite(_ data: Data, interactive: Bool) {
|
||||
guard let connection else { return }
|
||||
let output = interactive ? configuration.transformInteractiveInput(data) : data
|
||||
if interactive, configuration.localEcho {
|
||||
let echo = Data(data.flatMap { $0 == 0x0D ? [0x0D, 0x0A] : [$0] })
|
||||
terminalBackend.receive(echo)
|
||||
let source = interactive ? configuration.transformInteractiveInput(data) : data
|
||||
let output: Data
|
||||
do {
|
||||
output = try inputConverter.convert(source).data
|
||||
} catch {
|
||||
if charsetError == nil { charsetError = error.localizedDescription }
|
||||
return
|
||||
}
|
||||
if interactive, configuration.localEcho { terminalBackend.receive(source) }
|
||||
Task {
|
||||
do { try await connection.write(output) } catch {
|
||||
await MainActor.run { self.fail(error.localizedDescription) }
|
||||
@@ -172,6 +212,21 @@ final class SerialSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func finishCharsetOutput() {
|
||||
do {
|
||||
let converted = try outputConverter.finish(recoveringInvalidInput: true)
|
||||
if !converted.data.isEmpty { terminalBackend.receive(converted.data) }
|
||||
if converted.rejectedInput, charsetError == nil {
|
||||
charsetError =
|
||||
RemoteCharsetConversionError(
|
||||
charset: configuration.charset, direction: .decoding
|
||||
).localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if charsetError == nil { charsetError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
func requestTerminalFocus() {
|
||||
#if canImport(AppKit)
|
||||
terminalViewOwner.requestFocus()
|
||||
@@ -227,6 +282,18 @@ struct SerialTerminalView: View {
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { TerminalFontCatalog.shared.refresh() }
|
||||
}
|
||||
.alert("Charset Error", isPresented: charsetErrorPresented) {
|
||||
Button("OK", role: .cancel) { session.charsetError = nil }
|
||||
} message: {
|
||||
Text(session.charsetError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var charsetErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { session.charsetError != nil },
|
||||
set: { if !$0 { session.charsetError = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -166,6 +166,18 @@ struct TerminalPlaceholderView: View {
|
||||
} message: {
|
||||
Text("Use this choice for all conflicts in this batch.")
|
||||
}
|
||||
.alert("Charset Error", isPresented: charsetErrorPresented) {
|
||||
Button("OK", role: .cancel) { sshSession.charsetError = nil }
|
||||
} message: {
|
||||
Text(sshSession.charsetError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var charsetErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { sshSession.charsetError != nil },
|
||||
set: { if !$0 { sshSession.charsetError = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -118,6 +118,8 @@ private actor AuthorizedKeyConnection: SSHConnectionProtocol {
|
||||
switch stdin {
|
||||
case .plain(let bytes):
|
||||
input = String(decoding: bytes, as: UTF8.self)
|
||||
case .text(let value):
|
||||
input = value
|
||||
case .secret(let secret):
|
||||
defer { secret.clear() }
|
||||
input = try secret.withUnsafeBytes { String(decoding: $0, as: UTF8.self) }
|
||||
|
||||
@@ -2,6 +2,7 @@ import AgentKit
|
||||
import CSSH2
|
||||
import Darwin
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
@@ -29,6 +30,30 @@ struct SSHForwardingPumpTests {
|
||||
#expect(queued == Array("sudo-password\n".utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
func textCommandInputUsesCharsetWhilePlainInputStaysByteTransparent() throws {
|
||||
let textQueue = SSHByteQueue()
|
||||
try SSHCommandInput.text("café").append(to: textQueue, charset: .isoLatin1)
|
||||
#expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9, 0x0A]))
|
||||
|
||||
let raw = Data("café".utf8)
|
||||
let plainQueue = SSHByteQueue()
|
||||
try SSHCommandInput.plain(raw).append(to: plainQueue, charset: .isoLatin1)
|
||||
#expect(drain(plainQueue) == raw + Data([0x0A]))
|
||||
}
|
||||
|
||||
private func drain(_ queue: SSHByteQueue) -> Data {
|
||||
var result = Data()
|
||||
while !queue.isEmpty {
|
||||
let count = queue.withFirstBytes { bytes -> Int in
|
||||
result.append(contentsOf: bytes)
|
||||
return bytes.count
|
||||
}
|
||||
queue.consume(count)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Test func blockedWriteKeepsItsPointerLengthAndBytesWhileMoreDataArrives() {
|
||||
let queue = SSHByteQueue()
|
||||
queue.append(Data(repeating: 0x31, count: 100))
|
||||
|
||||
@@ -41,6 +41,7 @@ struct SerialFeatureTests {
|
||||
#expect(configuration.flowControl == .none)
|
||||
#expect(configuration.enterKey == .carriageReturn)
|
||||
#expect(!configuration.localEcho)
|
||||
#expect(configuration.charset == .utf8)
|
||||
#expect(configuration.transformInteractiveInput(Data("a\r".utf8)) == Data("a\r".utf8))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ struct SnippetTargetPickerRenderingTests {
|
||||
await fixture.settle()
|
||||
try fixture.sendKey(" ", code: 49)
|
||||
try fixture.performKeyEquivalent("\u{1B}", code: 53)
|
||||
#expect(capture.backCount >= 1)
|
||||
#expect(capture.backCount == 1)
|
||||
#expect(capture.saved == nil)
|
||||
let cancelBackCount = capture.backCount
|
||||
|
||||
@@ -56,7 +56,7 @@ struct SnippetTargetPickerRenderingTests {
|
||||
await fixture.settle()
|
||||
try fixture.sendKey(" ", code: 49)
|
||||
try fixture.performKeyEquivalent("\r", code: 36)
|
||||
#expect(capture.backCount > cancelBackCount)
|
||||
#expect(capture.backCount == cancelBackCount + 1)
|
||||
#expect(capture.saved == [first.id, second.id])
|
||||
}
|
||||
|
||||
|
||||
@@ -2373,6 +2373,10 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-liconv",
|
||||
);
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
|
||||
@@ -2433,6 +2437,10 @@
|
||||
MARKETING_VERSION = 1.6.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-liconv",
|
||||
);
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
|
||||
|
||||
Reference in new issue
Block a user