From 41f1a97cf91b76745ae94e6e3dd1d0920ef6ce73 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 23 Sep 2026 20:18:40 +0800 Subject: [PATCH] Bypass charset encode errors --- FrameworkTests/RemoteCharsetTests.swift | 33 ++++++++-- .../SSH/RemoteCharsetConverter.swift | 65 +++++++------------ .../SSH/SSHConnection.swift | 6 +- .../SSH/SSHPTYChannel.swift | 26 +------- .../Telnet/TelnetConnection.swift | 19 ++---- TFI/Features/Workspaces/MobileWorkspace.swift | 12 ---- .../Terminal/DesktopTerminalSurface.swift | 23 +------ TFM/Features/Terminal/SSHSessionModel.swift | 8 --- .../Terminal/SerialSessionModel.swift | 18 +---- .../Terminal/TelnetSessionModel.swift | 15 ----- .../Terminal/TerminalPlaceholderView.swift | 9 +-- TFMTests/SSHForwardingPumpTests.swift | 6 +- TFMTests/SerialFeatureTests.swift | 52 ++++++++++++++- 13 files changed, 119 insertions(+), 173 deletions(-) diff --git a/FrameworkTests/RemoteCharsetTests.swift b/FrameworkTests/RemoteCharsetTests.swift index a687eb0a..0282ac8f 100644 --- a/FrameworkTests/RemoteCharsetTests.swift +++ b/FrameworkTests/RemoteCharsetTests.swift @@ -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) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift index c47e81c7..c32a6f16 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift @@ -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() { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift index 16980ef6..476b91ee 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift @@ -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 { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index beba1f18..787e0f7d 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -644,7 +644,6 @@ public actor SSHPTYChannel { public typealias ExitHandler = @Sendable (Result) -> 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 diff --git a/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift b/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift index 2a8ccc1a..e4b293fa 100644 --- a/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift @@ -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() } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index c3e67f9d..ce8ad339 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 } diff --git a/TFM/Features/Terminal/DesktopTerminalSurface.swift b/TFM/Features/Terminal/DesktopTerminalSurface.swift index 6b513580..b0535f84 100644 --- a/TFM/Features/Terminal/DesktopTerminalSurface.swift +++ b/TFM/Features/Terminal/DesktopTerminalSurface.swift @@ -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 { - Binding( - get: { charsetError != nil }, - set: { if !$0 { charsetError = nil } } - ) } } extension View { - func desktopTerminalChrome( - state: TerminalViewState, - charsetError: Binding - ) -> 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 { diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 11205272..b5c3bd05 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 diff --git a/TFM/Features/Terminal/SerialSessionModel.swift b/TFM/Features/Terminal/SerialSessionModel.swift index a9ea0b3f..b0b16096 100644 --- a/TFM/Features/Terminal/SerialSessionModel.swift +++ b/TFM/Features/Terminal/SerialSessionModel.swift @@ -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 { - Binding( - get: { session.charsetError }, - set: { session.charsetError = $0 } - ) - } - } diff --git a/TFM/Features/Terminal/TelnetSessionModel.swift b/TFM/Features/Terminal/TelnetSessionModel.swift index 7447a58a..bf4ee1a1 100644 --- a/TFM/Features/Terminal/TelnetSessionModel.swift +++ b/TFM/Features/Terminal/TelnetSessionModel.swift @@ -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 { - Binding( - get: { session.charsetError }, - set: { session.charsetError = $0 } - ) - } - } diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 2b26c581..accdbfc4 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -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 { - Binding( - get: { sshSession.charsetError }, - set: { sshSession.charsetError = $0 } - ) - } - @ViewBuilder private var uploadDecoration: some View { if let uploadNotice { diff --git a/TFMTests/SSHForwardingPumpTests.swift b/TFMTests/SSHForwardingPumpTests.swift index b41cb009..6e5246eb 100644 --- a/TFMTests/SSHForwardingPumpTests.swift +++ b/TFMTests/SSHForwardingPumpTests.swift @@ -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() diff --git a/TFMTests/SerialFeatureTests.swift b/TFMTests/SerialFeatureTests.swift index 0fd69196..e9bdaee8 100644 --- a/TFMTests/SerialFeatureTests.swift +++ b/TFMTests/SerialFeatureTests.swift @@ -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() }