Bypass charset encode errors
This commit is contained in:
1 parent
76c4e9872f
commit
41f1a97cf9
13 files changed
+119
-173
No files matched your search
@@ -37,10 +37,14 @@ struct RemoteCharsetTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func encodingRemainsStrictWhileUTF8DecodingBypassesInvalidBytes() throws {
|
||||
#expect(throws: RemoteCharsetConversionError.self) {
|
||||
_ = try RemoteCharsetStreamConverter.encode("🙂", charset: .isoLatin1)
|
||||
}
|
||||
func utf8ConversionBypassesInvalidBytesInBothDirections() throws {
|
||||
let encoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .encoding)
|
||||
let encodedFirst = try encoder.convert(Data([0x41, 0xE4]))
|
||||
let encodedSecond = try encoder.convert(Data([0xB8, 0xFF, 0x42]))
|
||||
#expect(encodedFirst.data + encodedSecond.data == Data([0x41, 0xE4, 0xB8, 0xFF, 0x42]))
|
||||
#expect(!encodedFirst.rejectedInput)
|
||||
#expect(!encodedSecond.rejectedInput)
|
||||
#expect(try encoder.finish().data.isEmpty)
|
||||
|
||||
let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding)
|
||||
let first = try decoder.convert(Data([0x41, 0xE4]))
|
||||
@@ -52,6 +56,27 @@ struct RemoteCharsetTests {
|
||||
#expect(try RemoteCharsetStreamConverter.decode(Data([0x41, 0xFF, 0x42]), charset: .utf8) == "A�B")
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyEncoderBypassesUnrepresentableAndIncompleteUTF8BytesInPlace() throws {
|
||||
let emoji = Data("🙂".utf8)
|
||||
let encoder = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
|
||||
let first = try encoder.convert(Data([0x63, 0x61, 0x66, 0xC3, 0xA9, emoji[0], emoji[1]]))
|
||||
let second = try encoder.convert(Data([emoji[2], emoji[3], 0x66, 0x69, 0x6E]))
|
||||
#expect(first.data + second.data == Data([0x63, 0x61, 0x66, 0xE9]) + emoji + Data("fin".utf8))
|
||||
#expect(!first.rejectedInput)
|
||||
#expect(!second.rejectedInput)
|
||||
#expect(try encoder.finish().data.isEmpty)
|
||||
|
||||
let invalid = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
|
||||
#expect(try invalid.convert(Data([0x41, 0xFF, 0x42])).data == Data([0x41, 0xFF, 0x42]))
|
||||
|
||||
let incomplete = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
|
||||
#expect(try incomplete.convert(Data([0x41, 0xE2, 0x82])).data == Data([0x41]))
|
||||
#expect(try incomplete.finish().data == Data([0xE2, 0x82]))
|
||||
|
||||
#expect(try RemoteCharsetStreamConverter.encode("café🙂fin", charset: .isoLatin1) == first.data + second.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyDecoderBypassesInvalidAndIncompleteBytesInPlace() throws {
|
||||
let prefix = try RemoteCharsetStreamConverter.encode("服", charset: .gb18030)
|
||||
|
||||
@@ -38,8 +38,9 @@ public nonisolated struct RemoteCharsetConversionError: LocalizedError, Sendable
|
||||
}
|
||||
}
|
||||
|
||||
/// A stateful iconv adapter. Encoding is strict. Decoding preserves malformed
|
||||
/// source bytes verbatim so receiving text never interrupts a byte stream.
|
||||
/// A stateful iconv adapter that preserves unconvertible source bytes verbatim.
|
||||
/// Valid input is converted while malformed, incomplete, or unrepresentable bytes
|
||||
/// remain in place so Charset conversion never interrupts a byte stream.
|
||||
public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable {
|
||||
public enum Direction: Sendable { case encoding, decoding }
|
||||
|
||||
@@ -68,11 +69,8 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
let destination = direction == .encoding ? charset.iconvName : RemoteCharset.utf8.iconvName
|
||||
let descriptor = iconv_open(destination, source)
|
||||
guard descriptor != iconv_t(bitPattern: -1) else {
|
||||
if direction == .decoding {
|
||||
self.descriptor = nil
|
||||
return
|
||||
}
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: .encoding)
|
||||
self.descriptor = nil
|
||||
return
|
||||
}
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
@@ -82,8 +80,8 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
}
|
||||
|
||||
/// Converts one stream fragment. Incomplete source characters are retained.
|
||||
/// Decoding bypasses malformed source bytes verbatim. Encoding can optionally
|
||||
/// discard invalid UTF-8 input with `recoveringInvalidInput`.
|
||||
/// Bytes that cannot be converted are emitted verbatim. The recovery argument
|
||||
/// remains for source compatibility; conversion no longer rejects input.
|
||||
public func convert(_ data: Data, recoveringInvalidInput: Bool = false) throws -> Output {
|
||||
try lock.withLock {
|
||||
try convertLocked(data, recoveringInvalidInput: recoveringInvalidInput)
|
||||
@@ -94,7 +92,7 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
var source = pending
|
||||
source.append(data)
|
||||
pending.removeAll(keepingCapacity: true)
|
||||
if direction == .decoding, charset == .utf8 || descriptor == nil {
|
||||
if charset == .utf8 || descriptor == nil {
|
||||
return Output(data: source, rejectedInput: false)
|
||||
}
|
||||
do {
|
||||
@@ -108,7 +106,7 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
}
|
||||
}
|
||||
|
||||
/// Finishes the stream. An incomplete trailing character is rejected.
|
||||
/// Finishes the stream, emitting an incomplete trailing character verbatim.
|
||||
public func finish(recoveringInvalidInput: Bool = false) throws -> Output {
|
||||
try lock.withLock {
|
||||
try finishLocked(recoveringInvalidInput: recoveringInvalidInput)
|
||||
@@ -120,6 +118,9 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
pending.removeAll(keepingCapacity: false)
|
||||
reset()
|
||||
}
|
||||
if charset == .utf8 || descriptor == nil {
|
||||
return Output(data: pending, rejectedInput: false)
|
||||
}
|
||||
let converted = try convert(pending, final: true, recoveringInvalidInput: recoveringInvalidInput)
|
||||
return Output(data: converted.output, rejectedInput: converted.rejectedInput)
|
||||
}
|
||||
@@ -138,20 +139,16 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
return String(decoding: output, as: UTF8.self)
|
||||
}
|
||||
|
||||
private var errorDirection: RemoteCharsetConversionError.Direction {
|
||||
direction == .encoding ? .encoding : .decoding
|
||||
}
|
||||
|
||||
private func convert(
|
||||
_ data: Data, final: Bool, recoveringInvalidInput: Bool
|
||||
) throws -> Conversion {
|
||||
_ = recoveringInvalidInput
|
||||
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
|
||||
bytes.withUnsafeMutableBytes { rawBuffer in
|
||||
var input = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
|
||||
var inputCount = rawBuffer.count
|
||||
|
||||
@@ -176,26 +173,15 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
}
|
||||
inputCount = 0
|
||||
case EINVAL, EILSEQ:
|
||||
guard input != nil, inputCount > 0 else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
if direction == .decoding {
|
||||
output.append(UInt8(bitPattern: input!.pointee))
|
||||
} else {
|
||||
guard recoveringInvalidInput else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
rejectedInput = true
|
||||
}
|
||||
guard let currentInput = input, inputCount > 0 else { return }
|
||||
output.append(UInt8(bitPattern: currentInput.pointee))
|
||||
self.reset()
|
||||
inputCount -= 1
|
||||
// iconv updates `input`; advance past the bypassed source byte.
|
||||
input = input?.advanced(by: 1)
|
||||
input = currentInput.advanced(by: 1)
|
||||
default:
|
||||
guard direction == .decoding, input != nil, inputCount > 0 else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
output.append(Data(bytes: input!, count: inputCount))
|
||||
guard let input, inputCount > 0 else { return }
|
||||
output.append(Data(bytes: input, count: inputCount))
|
||||
self.reset()
|
||||
inputCount = 0
|
||||
}
|
||||
@@ -203,17 +189,10 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
|
||||
}
|
||||
|
||||
if final, !remainder.isEmpty {
|
||||
if direction == .decoding {
|
||||
output.append(remainder)
|
||||
remainder.removeAll()
|
||||
} else if recoveringInvalidInput {
|
||||
rejectedInput = true
|
||||
remainder.removeAll()
|
||||
} else {
|
||||
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
|
||||
}
|
||||
output.append(remainder)
|
||||
remainder.removeAll()
|
||||
}
|
||||
return Conversion(output: output, remainder: remainder, rejectedInput: rejectedInput)
|
||||
return Conversion(output: output, remainder: remainder, rejectedInput: false)
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
|
||||
@@ -28,9 +28,11 @@ 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.
|
||||
/// Text converted to the connected Host's Charset. Unrepresentable UTF-8
|
||||
/// bytes remain in place so command input is never discarded.
|
||||
case text(String)
|
||||
/// UTF-8 secret text converted without materializing a Swift String.
|
||||
/// UTF-8 secret text converted with the same byte-preserving fallback,
|
||||
/// without materializing a Swift String.
|
||||
case secret(AgentSecret)
|
||||
|
||||
func append(to queue: SSHByteQueue, charset: RemoteCharset = .utf8) throws {
|
||||
|
||||
@@ -644,7 +644,6 @@ 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?
|
||||
@@ -750,10 +749,6 @@ public actor SSHPTYChannel {
|
||||
environmentRejection = rejection
|
||||
}
|
||||
|
||||
public func configureCharsetErrors(_ handler: @escaping CharsetErrorHandler) {
|
||||
worker.setCharsetErrorHandler(handler)
|
||||
}
|
||||
|
||||
public func write(_ data: Data) {
|
||||
worker.write(data)
|
||||
}
|
||||
@@ -776,8 +771,6 @@ 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")
|
||||
}()
|
||||
@@ -806,7 +799,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
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!
|
||||
|
||||
@@ -1199,26 +1191,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
integration.noteUserInput()
|
||||
shellIntegration = integration
|
||||
}
|
||||
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)
|
||||
}
|
||||
let converted = (try? inputConverter?.convert(data).data) ?? data
|
||||
if !converted.isEmpty { pendingWrites.append(converted) }
|
||||
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
|
||||
|
||||
@@ -7,7 +7,6 @@ import TermindDomain
|
||||
public nonisolated final class TelnetConnection: @unchecked Sendable {
|
||||
public typealias OutputHandler = @Sendable (Data) -> Void
|
||||
public typealias EventHandler = @Sendable (TelnetConnectionEvent) -> Void
|
||||
public typealias ErrorHandler = @Sendable (String) -> Void
|
||||
public typealias CloseHandler = @Sendable (String?) -> Void
|
||||
|
||||
private let worker: TelnetConnectionWorker
|
||||
@@ -15,11 +14,9 @@ public nonisolated final class TelnetConnection: @unchecked Sendable {
|
||||
public init(
|
||||
output: @escaping OutputHandler,
|
||||
event: @escaping EventHandler = { _ in },
|
||||
charsetError: @escaping ErrorHandler = { _ in },
|
||||
closed: @escaping CloseHandler = { _ in }
|
||||
) {
|
||||
worker = TelnetConnectionWorker(
|
||||
output: output, event: event, charsetError: charsetError, closed: closed)
|
||||
worker = TelnetConnectionWorker(output: output, event: event, closed: closed)
|
||||
}
|
||||
|
||||
public func connect(
|
||||
@@ -42,7 +39,6 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "com.akinokaede.termind.telnet")
|
||||
private let output: TelnetConnection.OutputHandler
|
||||
private let event: TelnetConnection.EventHandler
|
||||
private let charsetError: TelnetConnection.ErrorHandler
|
||||
private let closed: TelnetConnection.CloseHandler
|
||||
|
||||
private var connection: NWConnection?
|
||||
@@ -65,12 +61,10 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
|
||||
init(
|
||||
output: @escaping TelnetConnection.OutputHandler,
|
||||
event: @escaping TelnetConnection.EventHandler,
|
||||
charsetError: @escaping TelnetConnection.ErrorHandler,
|
||||
closed: @escaping TelnetConnection.CloseHandler
|
||||
) {
|
||||
self.output = output
|
||||
self.event = event
|
||||
self.charsetError = charsetError
|
||||
self.closed = closed
|
||||
}
|
||||
|
||||
@@ -236,12 +230,8 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
|
||||
guard !data.isEmpty else { return }
|
||||
queue.async { [self] in
|
||||
guard connection != nil else { return }
|
||||
do {
|
||||
let encoded = try inputConverter?.convert(data).data ?? data
|
||||
sendApplicationData(encoded)
|
||||
} catch {
|
||||
charsetError(error.localizedDescription)
|
||||
}
|
||||
let encoded = (try? inputConverter?.convert(data).data) ?? data
|
||||
sendApplicationData(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,9 +371,8 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
|
||||
sendApplicationLine(encoded)
|
||||
}
|
||||
|
||||
private func failAutomaticLogin(_ error: any Error) {
|
||||
private func failAutomaticLogin(_: any Error) {
|
||||
configuration?.clearSecret()
|
||||
charsetError(error.localizedDescription)
|
||||
event(.continuingManually)
|
||||
markReady()
|
||||
}
|
||||
|
||||
@@ -730,12 +730,6 @@ 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: TermindCopy.Terminal.rejectedEnvironmentVariables(list))
|
||||
@@ -936,12 +930,6 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
}
|
||||
},
|
||||
charsetError: { [weak self] message in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == token, self.operationError == nil else { return }
|
||||
self.operationError = message
|
||||
}
|
||||
},
|
||||
closed: { [weak self] message in
|
||||
Task { @MainActor in
|
||||
guard let self, self.generation == token else { return }
|
||||
|
||||
@@ -89,7 +89,6 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable {
|
||||
|
||||
private struct DesktopTerminalChrome: ViewModifier {
|
||||
let state: TerminalViewState
|
||||
@Binding var charsetError: String?
|
||||
@TerminalAppearanceSettings private var appearance
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
@@ -101,27 +100,12 @@ private struct DesktopTerminalChrome: ViewModifier {
|
||||
.onChange(of: appearance) { _, value in
|
||||
state.applyPreferences(value.preferences, font: value.font)
|
||||
}
|
||||
.alert(TermindCopy.Terminal.charsetError.swiftUIKey, isPresented: charsetErrorPresented) {
|
||||
Button(TermindCopy.Common.ok.swiftUIKey, role: .cancel) { charsetError = nil }
|
||||
} message: {
|
||||
Text(charsetError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var charsetErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { charsetError != nil },
|
||||
set: { if !$0 { charsetError = nil } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func desktopTerminalChrome(
|
||||
state: TerminalViewState,
|
||||
charsetError: Binding<String?>
|
||||
) -> some View {
|
||||
modifier(DesktopTerminalChrome(state: state, charsetError: charsetError))
|
||||
func desktopTerminalChrome(state: TerminalViewState) -> some View {
|
||||
modifier(DesktopTerminalChrome(state: state))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +117,6 @@ struct SimpleDesktopTerminalView: View {
|
||||
let journey: ConnectionJourney
|
||||
let isVisible: Bool
|
||||
let requestsFocus: Bool
|
||||
@Binding var charsetError: String?
|
||||
let connectIfNeeded: () -> Void
|
||||
let retry: () -> Void
|
||||
let requestFocus: () -> Void
|
||||
@@ -149,7 +132,7 @@ struct SimpleDesktopTerminalView: View {
|
||||
isVisible: isVisible,
|
||||
requestsFocus: requestsFocus && !journey.isPresented
|
||||
)
|
||||
.desktopTerminalChrome(state: state, charsetError: $charsetError)
|
||||
.desktopTerminalChrome(state: state)
|
||||
.overlay { connectionJourney }
|
||||
.focused($focused)
|
||||
.onAppear {
|
||||
|
||||
@@ -63,7 +63,6 @@ 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] = []
|
||||
@@ -361,7 +360,6 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
private func startConnection(using store: any RemoteSessionHostStore) {
|
||||
resetCommandGeneratorShellState()
|
||||
charsetError = nil
|
||||
remoteWindowTitle = nil
|
||||
homeResolution = .unavailable
|
||||
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
|
||||
@@ -829,12 +827,6 @@ 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
|
||||
|
||||
@@ -28,7 +28,6 @@ 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() }
|
||||
@@ -86,7 +85,6 @@ final class SerialSessionModel: Identifiable {
|
||||
|
||||
func connect() {
|
||||
startsOnMount = false
|
||||
charsetError = nil
|
||||
connectionTask?.cancel()
|
||||
let previousConnection = connection
|
||||
connection = nil
|
||||
@@ -165,13 +163,7 @@ final class SerialSessionModel: Identifiable {
|
||||
func handleTerminalWrite(_ data: Data, interactive: Bool) {
|
||||
guard let connection else { return }
|
||||
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
|
||||
}
|
||||
let output = (try? inputConverter.convert(source).data) ?? source
|
||||
if interactive, configuration.localEcho { terminalSurface.receive(source) }
|
||||
Task {
|
||||
do { try await connection.write(output) } catch {
|
||||
@@ -210,7 +202,6 @@ struct SerialTerminalView: View {
|
||||
journey: session.connectionJourney,
|
||||
isVisible: isVisible,
|
||||
requestsFocus: requestsFocus,
|
||||
charsetError: charsetError,
|
||||
connectIfNeeded: session.connectIfNeeded,
|
||||
retry: session.connect,
|
||||
requestFocus: session.requestTerminalFocus,
|
||||
@@ -218,11 +209,4 @@ struct SerialTerminalView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var charsetError: Binding<String?> {
|
||||
Binding(
|
||||
get: { session.charsetError },
|
||||
set: { session.charsetError = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,7 +22,6 @@ final class TelnetSessionModel: Identifiable {
|
||||
didSet { statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) }
|
||||
}
|
||||
var hasUnreadOutput = false
|
||||
var charsetError: String?
|
||||
@ObservationIgnored lazy var find = TerminalFindController(
|
||||
perform: { [weak self] in self?.performFind($0) },
|
||||
focus: { [weak self] in self?.requestTerminalFocus() }
|
||||
@@ -111,7 +110,6 @@ final class TelnetSessionModel: Identifiable {
|
||||
func connect(using store: any RemoteSessionHostStore) {
|
||||
startsOnMount = false
|
||||
statusStore = store
|
||||
charsetError = nil
|
||||
connectionTask?.cancel()
|
||||
connection?.disconnect()
|
||||
connection = nil
|
||||
@@ -144,11 +142,6 @@ final class TelnetSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
},
|
||||
charsetError: { [weak self] message in
|
||||
Task { @MainActor in
|
||||
if self?.charsetError == nil { self?.charsetError = message }
|
||||
}
|
||||
},
|
||||
closed: { [weak self] message in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connection != nil else { return }
|
||||
@@ -228,7 +221,6 @@ struct TelnetTerminalView: View {
|
||||
journey: session.connectionJourney,
|
||||
isVisible: isVisible,
|
||||
requestsFocus: requestsFocus,
|
||||
charsetError: charsetError,
|
||||
connectIfNeeded: { session.connectIfNeeded(using: app.hostLibrary) },
|
||||
retry: { session.connect(using: app.hostLibrary) },
|
||||
requestFocus: session.requestTerminalFocus,
|
||||
@@ -236,11 +228,4 @@ struct TelnetTerminalView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var charsetError: Binding<String?> {
|
||||
Binding(
|
||||
get: { session.charsetError },
|
||||
set: { session.charsetError = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -115,7 +115,7 @@ struct TerminalPlaceholderView: View {
|
||||
/// terminal alone answers for the pane.
|
||||
var body: some View {
|
||||
terminalSurface
|
||||
.desktopTerminalChrome(state: sshSession.terminalState, charsetError: charsetError)
|
||||
.desktopTerminalChrome(state: sshSession.terminalState)
|
||||
.overlay(alignment: .bottom) { uploadDecoration }
|
||||
.overlay { connectionJourney }
|
||||
.animation(Motion.panel, value: uploadNotice)
|
||||
@@ -164,13 +164,6 @@ struct TerminalPlaceholderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var charsetError: Binding<String?> {
|
||||
Binding(
|
||||
get: { sshSession.charsetError },
|
||||
set: { sshSession.charsetError = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var uploadDecoration: some View {
|
||||
if let uploadNotice {
|
||||
|
||||
@@ -31,10 +31,10 @@ struct SSHForwardingPumpTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func textCommandInputUsesCharsetWhilePlainInputStaysByteTransparent() throws {
|
||||
func textCommandInputUsesCharsetAndBypassesUnrepresentableBytes() throws {
|
||||
let textQueue = SSHByteQueue()
|
||||
try SSHCommandInput.text("café").append(to: textQueue, charset: .isoLatin1)
|
||||
#expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9, 0x0A]))
|
||||
try SSHCommandInput.text("café🙂").append(to: textQueue, charset: .isoLatin1)
|
||||
#expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9]) + Data("🙂\n".utf8))
|
||||
|
||||
let raw = Data("café".utf8)
|
||||
let plainQueue = SSHByteQueue()
|
||||
|
||||
@@ -47,6 +47,33 @@ nonisolated private struct EmittingSerialService: SerialPortServing {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private final class RecordingSerialConnection: SerialPortConnection, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storedWrites: [Data] = []
|
||||
|
||||
var writes: [Data] { lock.withLock { storedWrites } }
|
||||
|
||||
func write(_ data: Data) async throws { lock.withLock { storedWrites.append(data) } }
|
||||
func close() async {}
|
||||
}
|
||||
|
||||
nonisolated private struct RecordingSerialService: SerialPortServing {
|
||||
let connection: RecordingSerialConnection
|
||||
|
||||
func availablePorts() async -> [SerialPortDescriptor] { [] }
|
||||
func portUpdates() -> AsyncStream<[SerialPortDescriptor]> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
func open(
|
||||
_: SerialConnectionConfiguration,
|
||||
receive _: @escaping @Sendable (Data) -> Void,
|
||||
disconnected _: @escaping @Sendable (String) -> Void,
|
||||
progress _: @escaping @Sendable (SerialPortOpenStage) -> Void
|
||||
) async throws -> any SerialPortConnection {
|
||||
connection
|
||||
}
|
||||
}
|
||||
|
||||
@Suite
|
||||
struct SerialFeatureTests {
|
||||
@Test
|
||||
@@ -128,7 +155,7 @@ struct SerialFeatureTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func serialReceiveBypassesInvalidUTF8WithoutReportingACharsetError() async {
|
||||
func serialReceiveBypassesInvalidUTF8WithoutDisconnecting() async {
|
||||
let configuration = SerialConnectionConfiguration(devicePath: "/dev/ttyUSB0", charset: .utf8)
|
||||
let session = SerialSessionModel(
|
||||
configuration: configuration,
|
||||
@@ -140,7 +167,28 @@ struct SerialFeatureTests {
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(session.status == .connected)
|
||||
#expect(session.charsetError == nil)
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func serialSendBypassesUnrepresentableUTF8WithoutDisconnecting() async {
|
||||
let connection = RecordingSerialConnection()
|
||||
let configuration = SerialConnectionConfiguration(
|
||||
devicePath: "/dev/ttyUSB0", charset: .isoLatin1)
|
||||
let session = SerialSessionModel(
|
||||
configuration: configuration,
|
||||
service: RecordingSerialService(connection: connection)
|
||||
)
|
||||
|
||||
session.prepareForReopen()
|
||||
session.connectIfNeeded()
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
session.handleTerminalWrite(Data("café🙂".utf8), interactive: false)
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(connection.writes == [Data([0x63, 0x61, 0x66, 0xE9]) + Data("🙂".utf8)])
|
||||
#expect(session.status == .connected)
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user