239 lines
8.9 KiB
Swift
239 lines
8.9 KiB
Swift
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<Void, Never>?
|
|
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 {
|
|
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
|
|
)
|
|
}
|
|
|
|
}
|