// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Combine import GhosttyTerminal import SwiftUI import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import struct TermindDomain.Host @MainActor private final class TelnetSessionRelay { weak var owner: TelnetSessionModel? } @Observable final class TelnetSessionModel: Identifiable { let id = UUID() let hostID: Host.ID var title: String var status: ConnectionStatus = .disconnected { didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) } } let commandMonitor = TerminalCommandMonitor() private var commandObservation: TerminalCommandObservation? var hasUnreadOutput = false @ObservationIgnored lazy var find = TerminalFindController( perform: { [weak self] in self?.performFind($0) }, focus: { [weak self] in self?.requestTerminalFocus() } ) private let terminalSurface: PersistentTerminalViewOwner var terminalState: TerminalViewState { terminalSurface.state } var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface } private weak var statusStore: (any RemoteSessionHostStore)? private let transientHost: Host private let usernameHint: String? private var advisoryPassword: SSHSecret? private let isExternalTemporaryConnection: Bool private var connection: TelnetConnection? private var connectionTask: Task? private var startsOnMount = true private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24) let connectionController = RemoteSessionConnectionController() var connectionJourney: ConnectionJourney { connectionController.journey } var agentTemporaryTarget: AgentHostTarget? { guard statusStore?.host(hostID) == nil else { return nil } let state: String switch status { case .disconnected: state = "disconnected" case .connecting: state = "connecting" case .connected: state = "connected" case .reconnecting: state = "reconnecting" case .failed: state = "failed" } let address = transientHost.displayAddress(for: .telnet) return AgentHostTarget( id: hostID, displayName: title, address: address, tags: transientHost.tags, connectionState: state, isTemporary: true, preferredTerminalProtocol: .telnet, protocols: [.init(name: .telnet, address: address, connectionState: state)]) } init( host: Host, usernameHint: String? = nil, advisoryPassword: SSHSecret? = nil, isExternalTemporaryConnection: Bool = false ) { hostID = host.id title = host.name transientHost = host self.usernameHint = usernameHint self.advisoryPassword = advisoryPassword self.isExternalTemporaryConnection = isExternalTemporaryConnection let relay = TelnetSessionRelay() terminalSurface = PersistentTerminalViewOwner( write: { data, _ in Task { @MainActor in relay.owner?.connection?.write(data) } }, resize: { viewport in Task { @MainActor in relay.owner?.latestViewport = viewport relay.owner?.connection?.resize( columns: UInt16(clamping: viewport.columns), rows: UInt16(clamping: viewport.rows)) } } ) relay.owner = self commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor) connectionJourney.beginTelnet( host: host, username: usernameHint, mode: .connection) } func copyForReconnect() -> TelnetSessionModel? { TelnetSessionModel( host: statusStore?.host(hostID) ?? transientHost, usernameHint: usernameHint, isExternalTemporaryConnection: isExternalTemporaryConnection) } func prepareForReopen() { startsOnMount = true status = .connecting } func connectIfNeeded(using store: any RemoteSessionHostStore) { guard startsOnMount, status == .connecting else { return } connect(using: store) } func connect(using store: any RemoteSessionHostStore) { startsOnMount = false statusStore = store connectionTask?.cancel() connection?.disconnect() connection = nil let host = store.host(hostID) ?? transientHost guard host.telnetEnabled else { fail(String(localized: AppStrings.Connections.telnetIsDisabledForThisHost)) return } let username = usernameHint ?? store.identity(for: host, protocol: .telnet)?.username connectionJourney.beginTelnet( host: host, username: username, mode: connectionController.hasConnectedOnce ? .reconnection : .connection) status = connectionController.hasConnectedOnce ? .reconnecting : .connecting commandMonitor.beginConnection() let terminalSurface = terminalSurface let token = UUID() let connection = TelnetConnection( output: { [weak self] data in terminalSurface.receive(data) Task { @MainActor in self?.hasUnreadOutput = true } }, event: { [weak self] event in Task { @MainActor in guard let self, self.connection != nil else { return } self.connectionJourney.receive(event) if case .connected = event { _ = self.connectionController.markConnected() self.status = .connected self.connectionJourney.succeed() } } }, closed: { [weak self] message in Task { @MainActor in guard let self, self.connection != nil else { return } self.connection = nil self.fail(message ?? String(localized: AppStrings.Terminal.theTelnetConnectionClosed)) } } ) self.connection = connection let viewport = latestViewport connectionTask = Task { [weak self] in do { try await store.resolveHostAccess(host, protocol: .telnet) var configuration = try store.telnetConnectionConfiguration(for: host) if let usernameHint = self?.usernameHint { configuration.username = usernameHint } if let password = self?.advisoryPassword { configuration.password = password self?.advisoryPassword = nil } try await connection.connect( using: configuration, columns: UInt16(clamping: viewport.columns), rows: UInt16(clamping: viewport.rows)) } catch is CancellationError { await MainActor.run { self?.status = .disconnected } } catch { await MainActor.run { guard self?.connection != nil else { return } self?.connection = nil self?.fail(error.localizedDescription) } } _ = token } } func disconnect() { startsOnMount = false connectionTask?.cancel() connectionTask = nil connection?.disconnect() connection = nil status = .disconnected connectionController.disconnect() advisoryPassword?.clear() advisoryPassword = nil } func insert(_ text: String, submit: Bool = false) { connection?.write(Data((text + (submit ? "\r" : "")).utf8)) } private func fail(_ message: String) { guard status != .disconnected else { return } connection?.disconnect() connection = nil status = .failed(message) connectionJourney.fail(message) terminalSurface.appendNotice(message) } func requestTerminalFocus() { terminalSurface.requestFocus() } private func performFind(_ action: String) { terminalSurface.performBindingAction(action) } } struct TelnetTerminalView: View { let session: TelnetSessionModel let isVisible: Bool let requestsFocus: Bool let closePane: () -> Void @Environment(AppModel.self) private var app var body: some View { SimpleDesktopTerminalView( state: session.terminalState, owner: session.terminalViewOwner, journey: session.connectionJourney, isVisible: isVisible, requestsFocus: requestsFocus, connectIfNeeded: { session.connectIfNeeded(using: app.hostLibrary) }, retry: { session.connect(using: app.hostLibrary) }, requestFocus: session.requestTerminalFocus, closePane: closePane ) } }